From 8325645a19f306147b2a30d8c57fdae96b42542d Mon Sep 17 00:00:00 2001 From: d0x-dev Date: Sat, 23 May 2026 23:43:20 +0530 Subject: [PATCH 01/83] Refactor packages to com.darkxvenom.airbeats and update branding --- .github/ISSUE_TEMPLATE/bug-report-.md | 72 + .gitignore | 91 + CODE_OF_CONDUCT.md | 49 + CONTRIBUTING.md | 234 + LICENSE | 674 + README.en.md | 402 + app/.gitignore | 1 + app/build.gradle.kts | 164 + app/proguard-rules.pro | 91 + .../1.json | 297 + .../10.json | 814 + .../11.json | 796 + .../12.json | 812 + .../13.json | 812 + .../14.json | 824 + .../15.json | 854 + .../16.json | 931 + .../17.json | 953 + .../18.json | 993 + .../19.json | 1000 + .../2.json | 656 + .../3.json | 718 + .../4.json | 744 + .../5.json | 748 + .../6.json | 712 + .../7.json | 718 + .../8.json | 766 + .../9.json | 840 + app/src/debug/res/values/app_name.xml | 4 + app/src/debug/res/xml-v25/shortcuts.xml | 33 + app/src/main/AndroidManifest.xml | 201 + app/src/main/assets/splash_anim.json | 1 + app/src/main/ic_launcher-playstore.png | Bin 0 -> 96470 bytes .../main/java/com/darkxvenom/airbeats/App.kt | 189 + .../com/darkxvenom/airbeats/MainActivity.kt | 1829 ++ .../com/darkxvenom/airbeats/MusicWidget.kt | 308 + .../airbeats/MyFirebaseMessagingService.kt | 71 + .../airbeats/constants/Dimensions.kt | 34 + .../airbeats/constants/HistorySource.kt | 5 + .../airbeats/constants/LibraryFilter.kt | 10 + .../constants/MediaSessionConstants.kt | 15 + .../airbeats/constants/PreferenceKeys.kt | 518 + .../airbeats/constants/StatPeriod.kt | 95 + .../com/darkxvenom/airbeats/db/Converters.kt | 20 + .../com/darkxvenom/airbeats/db/DatabaseDao.kt | 1353 ++ .../darkxvenom/airbeats/db/MusicDatabase.kt | 465 + .../darkxvenom/airbeats/db/entities/Album.kt | 33 + .../airbeats/db/entities/AlbumArtistMap.kt | 29 + .../airbeats/db/entities/AlbumEntity.kt | 40 + .../airbeats/db/entities/AlbumWithSongs.kt | 36 + .../darkxvenom/airbeats/db/entities/Artist.kt | 21 + .../airbeats/db/entities/ArtistEntity.kt | 54 + .../darkxvenom/airbeats/db/entities/Event.kt | 27 + .../airbeats/db/entities/EventWithSong.kt | 17 + .../airbeats/db/entities/FormatEntity.kt | 17 + .../airbeats/db/entities/LocalItem.kt | 7 + .../airbeats/db/entities/LyricsEntity.kt | 14 + .../airbeats/db/entities/PlayCountEntity.kt | 16 + .../airbeats/db/entities/Playlist.kt | 33 + .../airbeats/db/entities/PlaylistEntity.kt | 56 + .../airbeats/db/entities/PlaylistSong.kt | 14 + .../airbeats/db/entities/PlaylistSongMap.kt | 31 + .../db/entities/PlaylistSongMapPreview.kt | 14 + .../airbeats/db/entities/RelatedSongMap.kt | 29 + .../airbeats/db/entities/SearchHistory.kt | 19 + .../airbeats/db/entities/SetVideoIdEntity.kt | 11 + .../darkxvenom/airbeats/db/entities/Song.kt | 44 + .../airbeats/db/entities/SongAlbumMap.kt | 29 + .../airbeats/db/entities/SongArtistMap.kt | 29 + .../airbeats/db/entities/SongEntity.kt | 60 + .../airbeats/db/entities/SongWithStats.kt | 12 + .../db/entities/SortedSongAlbumMap.kt | 14 + .../db/entities/SortedSongArtistMap.kt | 14 + .../com/darkxvenom/airbeats/di/AppModule.kt | 79 + .../airbeats/di/LyricsHelperEntryPoint.kt | 12 + .../airbeats/di/PreferenceModule.kt | 23 + .../airbeats/extensions/ContextExt.kt | 31 + .../airbeats/extensions/CoroutineExt.kt | 27 + .../darkxvenom/airbeats/extensions/FileExt.kt | 13 + .../darkxvenom/airbeats/extensions/ListExt.kt | 34 + .../airbeats/extensions/MediaItemExt.kt | 69 + .../airbeats/extensions/PlayerExt.kt | 104 + .../airbeats/extensions/QueueExt.kt | 113 + .../airbeats/extensions/StringExt.kt | 23 + .../darkxvenom/airbeats/extensions/UtilExt.kt | 8 + .../airbeats/lyrics/KuGouLyricsProvider.kt | 31 + .../airbeats/lyrics/LrcLibLyricsProvider.kt | 30 + .../darkxvenom/airbeats/lyrics/LyricsEntry.kt | 12 + .../airbeats/lyrics/LyricsHelper.kt | 112 + .../airbeats/lyrics/LyricsProvider.kt | 26 + .../darkxvenom/airbeats/lyrics/LyricsUtils.kt | 52 + .../airbeats/lyrics/YouTubeLyricsProvider.kt | 26 + .../lyrics/YouTubeSubtitleLyricsProvider.kt | 17 + .../darkxvenom/airbeats/models/ItemsPage.kt | 8 + .../airbeats/models/MediaMetadata.kt | 92 + .../airbeats/models/PersistPlayerState.kt | 13 + .../airbeats/models/PersistQueue.kt | 41 + .../airbeats/models/SimilarRecommendation.kt | 9 + .../airbeats/playback/DownloadUtil.kt | 156 + .../airbeats/playback/ExoDownloadService.kt | 107 + .../playback/MediaLibrarySessionCallback.kt | 429 + .../airbeats/playback/MusicService.kt | 1611 ++ .../airbeats/playback/PlayerConnection.kt | 682 + .../airbeats/playback/SleepTimer.kt | 67 + .../airbeats/playback/queues/EmptyQueue.kt | 14 + .../airbeats/playback/queues/ListQueue.kt | 19 + .../playback/queues/LocalAlbumRadio.kt | 53 + .../airbeats/playback/queues/Queue.kt | 40 + .../playback/queues/YouTubeAlbumRadio.kt | 48 + .../airbeats/playback/queues/YouTubeQueue.kt | 46 + .../airbeats/ui/component/AutoResizeText.kt | 95 + .../airbeats/ui/component/AvatarDisplay.kt | 231 + .../airbeats/ui/component/BigSeekBar.kt | 59 + .../airbeats/ui/component/BottomSheet.kt | 348 + .../airbeats/ui/component/BottomSheetMenu.kt | 86 + .../airbeats/ui/component/BottomSheetPage.kt | 117 + .../airbeats/ui/component/ChangelogButton.kt | 1250 ++ .../airbeats/ui/component/ChipsRow.kt | 195 + .../airbeats/ui/component/CircleIconButton.kt | 75 + .../ui/component/CreatePlaylistDialog.kt | 93 + .../ui/component/CurvedBottomNavigationBar.kt | 257 + .../ui/component/CustomAvatarSelector.kt | 820 + .../airbeats/ui/component/Dialog.kt | 320 + .../airbeats/ui/component/EmptyPlaceholder.kt | 47 + .../ui/component/EnhancedPreferenceEntry.kt | 231 + .../airbeats/ui/component/GridMenu.kt | 194 + .../airbeats/ui/component/HideOnScrollFAB.kt | 116 + .../airbeats/ui/component/IconButton.kt | 92 + .../darkxvenom/airbeats/ui/component/Items.kt | 2105 ++ .../airbeats/ui/component/Library.kt | 288 + .../airbeats/ui/component/LocaleManager.kt | 1068 + .../airbeats/ui/component/Lyrics.kt | 1734 ++ .../ui/component/NamePreferenceManager.kt | 40 + .../airbeats/ui/component/NameProvider.kt | 64 + .../airbeats/ui/component/NameSetupDialog.kt | 131 + .../airbeats/ui/component/NavigationTitle.kt | 77 + .../airbeats/ui/component/PlayerSlider.kt | 112 + .../airbeats/ui/component/PlayingIndicator.kt | 113 + .../airbeats/ui/component/Preference.kt | 358 + .../airbeats/ui/component/ReleaseNotesCard.kt | 83 + .../airbeats/ui/component/SearchBar.kt | 334 + .../airbeats/ui/component/SettingsCategory.kt | 236 + .../airbeats/ui/component/SettingsPage.kt | 83 + .../airbeats/ui/component/ShapeSelector.kt | 358 + .../airbeats/ui/component/ShareLyrics.kt | 801 + .../ui/component/ShareLyricsDialog.kt | 976 + .../airbeats/ui/component/SortHeader.kt | 110 + .../ThumbnailCornerRadiusSelector.kt | 528 + .../ui/component/VerticalFastScroller.kt | 377 + .../ui/component/shimmer/ButtonPlaceholder.kt | 21 + .../component/shimmer/GridItemPlaceholder.kt | 57 + .../component/shimmer/ListItemPlaceholder.kt | 53 + .../ui/component/shimmer/ShimmerHost.kt | 64 + .../ui/component/shimmer/TextPlaceholder.kt | 29 + .../airbeats/ui/menu/AddToPlaylistDialog.kt | 195 + .../darkxvenom/airbeats/ui/menu/AlbumMenu.kt | 375 + .../darkxvenom/airbeats/ui/menu/ArtistMenu.kt | 141 + .../airbeats/ui/menu/ImportPlaylistDialog.kt | 63 + .../darkxvenom/airbeats/ui/menu/LyricsMenu.kt | 338 + .../airbeats/ui/menu/OnlinePlaylistAdder.kt | 328 + .../darkxvenom/airbeats/ui/menu/PlayerMenu.kt | 624 + .../airbeats/ui/menu/PlaylistMenu.kt | 416 + .../airbeats/ui/menu/SelectionSongsMenu.kt | 527 + .../darkxvenom/airbeats/ui/menu/SongMenu.kt | 653 + .../airbeats/ui/menu/YouTubeAlbumMenu.kt | 353 + .../airbeats/ui/menu/YouTubeArtistMenu.kt | 227 + .../airbeats/ui/menu/YouTubePlaylistMenu.kt | 507 + .../airbeats/ui/menu/YouTubeSongMenu.kt | 340 + .../airbeats/ui/player/MiniPlayer.kt | 529 + .../airbeats/ui/player/PlaybackError.kt | 45 + .../darkxvenom/airbeats/ui/player/Player.kt | 1533 ++ .../darkxvenom/airbeats/ui/player/Queue.kt | 886 + .../airbeats/ui/player/Thumbnail.kt | 422 + .../airbeats/ui/screens/AccountScreen.kt | 163 + .../airbeats/ui/screens/AlbumScreen.kt | 714 + .../airbeats/ui/screens/ExploreScreen.kt | 219 + .../airbeats/ui/screens/HistoryScreen.kt | 506 + .../airbeats/ui/screens/HomeScreen.kt | 1271 ++ .../airbeats/ui/screens/InsightScreen.kt | 79 + .../airbeats/ui/screens/LoginScreen.kt | 228 + .../airbeats/ui/screens/NavigationBuilder.kt | 289 + .../airbeats/ui/screens/NewReleaseScreen.kt | 158 + .../darkxvenom/airbeats/ui/screens/Screens.kt | 39 + .../airbeats/ui/screens/StatsScreen.kt | 754 + .../ui/screens/YouTubeBrowseScreen.kt | 282 + .../ui/screens/artist/ArtistItemsScreen.kt | 297 + .../ui/screens/artist/ArtistScreen.kt | 680 + .../ui/screens/artist/ArtistSongsScreen.kt | 403 + .../ui/screens/library/CachePlaylistScreen.kt | 424 + .../ui/screens/library/LibraryAlbumsScreen.kt | 307 + .../screens/library/LibraryArtistsScreen.kt | 296 + .../ui/screens/library/LibraryMixScreen.kt | 722 + .../screens/library/LibraryPlaylistsScreen.kt | 554 + .../ui/screens/library/LibraryScreen.kt | 164 + .../ui/screens/library/LibrarySongsScreen.kt | 342 + .../ui/screens/library/LocalSongsScreen.kt | 499 + .../ui/screens/playlist/AutoPlaylistScreen.kt | 697 + .../screens/playlist/LocalPlaylistScreen.kt | 1250 ++ .../screens/playlist/OnlinePlaylistScreen.kt | 640 + .../ui/screens/playlist/TopPlaylistScreen.kt | 632 + .../ui/screens/search/LocalSearchScreen.kt | 263 + .../ui/screens/search/OnlineSearchResult.kt | 291 + .../ui/screens/search/OnlineSearchScreen.kt | 373 + .../ui/screens/settings/AboutScreen.kt | 534 + .../ui/screens/settings/AccountSettings.kt | 298 + .../ui/screens/settings/AppearanceSettings.kt | 715 + .../ui/screens/settings/BackupAndRestore.kt | 824 + .../ui/screens/settings/ContentSettings.kt | 221 + .../ui/screens/settings/DiscordLoginScreen.kt | 171 + .../ui/screens/settings/DiscordSettings.kt | 830 + .../ui/screens/settings/PlayerSettings.kt | 161 + .../ui/screens/settings/PrivacySettings.kt | 200 + .../ui/screens/settings/SettingsScreen.kt | 1513 ++ .../ui/screens/settings/StorageSettings.kt | 672 + .../com/darkxvenom/airbeats/ui/theme/Theme.kt | 202 + .../darkxvenom/airbeats/ui/utils/AppBar.kt | 73 + .../airbeats/ui/utils/FadingEdge.kt | 89 + .../airbeats/ui/utils/ItemWrapper.kt | 15 + .../utils/LazyGridSnapLayoutInfoProvider.kt | 70 + .../airbeats/ui/utils/NavControllerUtils.kt | 14 + .../airbeats/ui/utils/ScrollUtils.kt | 59 + .../airbeats/ui/utils/ShapeUtils.kt | 8 + .../airbeats/ui/utils/StringUtils.kt | 30 + .../airbeats/ui/utils/YouTubeUtils.kt | 21 + .../airbeats/utils/CoilBitmapLoader.kt | 49 + .../airbeats/utils/ComposeDebugUtils.kt | 100 + .../darkxvenom/airbeats/utils/DataStore.kt | 117 + .../darkxvenom/airbeats/utils/DiscordRPC.kt | 53 + .../airbeats/utils/LyricsImageGenerator.kt | 630 + .../utils/NetworkConnectivityObserver.kt | 59 + .../darkxvenom/airbeats/utils/NetworkUtils.kt | 20 + .../airbeats/utils/PlaylistImageUtils.kt | 69 + .../darkxvenom/airbeats/utils/ShapeUtils.kt | 54 + .../darkxvenom/airbeats/utils/StringUtils.kt | 31 + .../darkxvenom/airbeats/utils/SyncUtils.kt | 173 + .../com/darkxvenom/airbeats/utils/Updater.kt | 23 + .../com/darkxvenom/airbeats/utils/Utils.kt | 5 + .../airbeats/utils/YTPlayerUtils.kt | 334 + .../airbeats/utils/potoken/JavaScriptUtil.kt | 137 + .../utils/potoken/PoTokenException.kt | 13 + .../utils/potoken/PoTokenGenerator.kt | 107 + .../airbeats/utils/potoken/PoTokenResult.kt | 6 + .../airbeats/utils/potoken/PoTokenWebView.kt | 353 + .../airbeats/viewmodels/AccountViewModel.kt | 42 + .../airbeats/viewmodels/AlbumViewModel.kt | 58 + .../viewmodels/ArtistItemsViewModel.kt | 77 + .../airbeats/viewmodels/ArtistViewModel.kt | 45 + .../viewmodels/AutoPlaylistViewModel.kt | 86 + .../viewmodels/BackupRestoreViewModel.kt | 213 + .../airbeats/viewmodels/ExploreViewModel.kt | 74 + .../airbeats/viewmodels/HistoryViewModel.kt | 101 + .../airbeats/viewmodels/HomeViewModel.kt | 175 + .../airbeats/viewmodels/LibraryViewModels.kt | 367 + .../viewmodels/LocalPlaylistViewModel.kt | 87 + .../viewmodels/LocalSearchViewModel.kt | 93 + .../airbeats/viewmodels/LocalSong.kt | 23 + .../viewmodels/LocalSongsViewModel.kt | 145 + .../viewmodels/LyricsMenuViewModel.kt | 68 + .../viewmodels/MoodAndGenresViewModel.kt | 30 + .../viewmodels/NewReleaseViewModel.kt | 119 + .../viewmodels/OnlinePlaylistViewModel.kt | 47 + .../OnlineSearchSuggestionViewModel.kt | 83 + .../viewmodels/OnlineSearchViewModel.kt | 98 + .../airbeats/viewmodels/StatsViewModel.kt | 175 + .../viewmodels/TopPlaylistViewModel.kt | 36 + .../viewmodels/YouTubeBrowseViewModel.kt | 42 + app/src/main/logoxd-playstore.png | Bin 0 -> 18530 bytes app/src/main/res/drawable/account.xml | 9 + app/src/main/res/drawable/add.xml | 9 + app/src/main/res/drawable/airbeats.xml | 1029 + app/src/main/res/drawable/airbeats_logo.xml | 16157 ++++++++++++++++ .../main/res/drawable/airbeats_monochrome.xml | 1029 + app/src/main/res/drawable/album.xml | 16 + app/src/main/res/drawable/apps.xml | 9 + app/src/main/res/drawable/arrow_back.xml | 16 + app/src/main/res/drawable/arrow_downward.xml | 9 + app/src/main/res/drawable/arrow_forward.xml | 10 + app/src/main/res/drawable/arrow_top_left.xml | 9 + app/src/main/res/drawable/arrow_upward.xml | 9 + app/src/main/res/drawable/artist.xml | 9 + app/src/main/res/drawable/attach_money.xml | 9 + app/src/main/res/drawable/auto_awesome.xml | 9 + app/src/main/res/drawable/backup.xml | 9 + app/src/main/res/drawable/bedtime.xml | 9 + app/src/main/res/drawable/block.xml | 9 + app/src/main/res/drawable/bookmark.xml | 9 + app/src/main/res/drawable/bookmark_filled.xml | 9 + app/src/main/res/drawable/bug_report.xml | 9 + app/src/main/res/drawable/cached.xml | 9 + app/src/main/res/drawable/car.xml | 9 + app/src/main/res/drawable/casino.xml | 10 + app/src/main/res/drawable/check.xml | 9 + app/src/main/res/drawable/check_box.xml | 9 + app/src/main/res/drawable/check_circle.xml | 9 + app/src/main/res/drawable/chevron_right.xml | 9 + app/src/main/res/drawable/clear_all.xml | 9 + app/src/main/res/drawable/close.xml | 9 + app/src/main/res/drawable/cloud_download.xml | 9 + app/src/main/res/drawable/cloud_lock.xml | 9 + app/src/main/res/drawable/codigo.xml | 9 + app/src/main/res/drawable/compare_arrows.xml | 9 + app/src/main/res/drawable/content_copy.xml | 9 + app/src/main/res/drawable/contrast.xml | 9 + app/src/main/res/drawable/dark_mode.xml | 9 + app/src/main/res/drawable/delete.xml | 9 + app/src/main/res/drawable/delete_history.xml | 9 + .../res/drawable/deployed_code_update.xml | 9 + app/src/main/res/drawable/deselect.xml | 9 + app/src/main/res/drawable/desktop_windows.xml | 9 + app/src/main/res/drawable/discord.xml | 9 + app/src/main/res/drawable/discover_tune.xml | 9 + app/src/main/res/drawable/donate.xml | 9 + app/src/main/res/drawable/done.xml | 9 + app/src/main/res/drawable/double_arrow.xml | 9 + app/src/main/res/drawable/download.xml | 9 + app/src/main/res/drawable/drag_handle.xml | 9 + app/src/main/res/drawable/edit.xml | 9 + app/src/main/res/drawable/email.xml | 9 + app/src/main/res/drawable/empty_splash.xml | 4 + app/src/main/res/drawable/empty_state.xml | 9 + app/src/main/res/drawable/equalizer.xml | 9 + app/src/main/res/drawable/error.xml | 10 + app/src/main/res/drawable/expand_less.xml | 9 + app/src/main/res/drawable/expand_more.xml | 9 + app/src/main/res/drawable/explicit.xml | 9 + app/src/main/res/drawable/explore.xml | 9 + app/src/main/res/drawable/explore_filled.xml | 9 + .../main/res/drawable/explore_outlined.xml | 16 + app/src/main/res/drawable/extension.xml | 9 + app/src/main/res/drawable/facebook.xml | 9 + app/src/main/res/drawable/fast_forward.xml | 9 + app/src/main/res/drawable/favorite.xml | 10 + app/src/main/res/drawable/favorite_border.xml | 9 + app/src/main/res/drawable/feedback.xml | 9 + app/src/main/res/drawable/folder.xml | 9 + .../main/res/drawable/format_align_center.xml | 9 + .../main/res/drawable/format_align_left.xml | 10 + app/src/main/res/drawable/format_color.xml | 9 + app/src/main/res/drawable/fullscreen.xml | 9 + app/src/main/res/drawable/fullscreen_exit.xml | 9 + app/src/main/res/drawable/funbeta.xml | 9 + app/src/main/res/drawable/github.xml | 40 + app/src/main/res/drawable/google.xml | 11 + app/src/main/res/drawable/gradient.xml | 12 + app/src/main/res/drawable/graphic_eq.xml | 9 + app/src/main/res/drawable/grid_view.xml | 9 + app/src/main/res/drawable/group.xml | 9 + app/src/main/res/drawable/help.xml | 9 + app/src/main/res/drawable/history.xml | 9 + app/src/main/res/drawable/home.xml | 9 + app/src/main/res/drawable/home_filled.xml | 10 + app/src/main/res/drawable/home_outlined.xml | 13 + app/src/main/res/drawable/ia_icon.xml | 9 + app/src/main/res/drawable/image.xml | 9 + app/src/main/res/drawable/info.xml | 9 + app/src/main/res/drawable/input.xml | 9 + app/src/main/res/drawable/instagram.xml | 9 + app/src/main/res/drawable/language.xml | 9 + app/src/main/res/drawable/liberapay.xml | 13 + app/src/main/res/drawable/library_add.xml | 9 + .../main/res/drawable/library_add_check.xml | 9 + app/src/main/res/drawable/library_music.xml | 9 + .../res/drawable/library_music_filled.xml | 16 + .../res/drawable/library_music_outlined.xml | 16 + app/src/main/res/drawable/license.xml | 9 + app/src/main/res/drawable/lightbulb.xml | 9 + app/src/main/res/drawable/line_curve.xml | 9 + app/src/main/res/drawable/link.xml | 9 + app/src/main/res/drawable/list.xml | 9 + app/src/main/res/drawable/location_on.xml | 9 + app/src/main/res/drawable/lock.xml | 9 + app/src/main/res/drawable/lock_open.xml | 9 + app/src/main/res/drawable/login.xml | 10 + app/src/main/res/drawable/logout.xml | 11 + app/src/main/res/drawable/lyrics.xml | 9 + app/src/main/res/drawable/manage_search.xml | 9 + app/src/main/res/drawable/menu.xml | 9 + app/src/main/res/drawable/menu_book.xml | 9 + app/src/main/res/drawable/mood.xml | 9 + app/src/main/res/drawable/more_horiz.xml | 9 + app/src/main/res/drawable/more_vert.xml | 9 + app/src/main/res/drawable/music_note.xml | 9 + app/src/main/res/drawable/nav_bar.xml | 9 + app/src/main/res/drawable/navigate_next.xml | 10 + .../main/res/drawable/notification_off.xml | 9 + app/src/main/res/drawable/notification_on.xml | 9 + app/src/main/res/drawable/offline.xml | 9 + app/src/main/res/drawable/open_in_new.xml | 9 + app/src/main/res/drawable/palette.xml | 9 + app/src/main/res/drawable/pause.xml | 9 + app/src/main/res/drawable/paypal.xml | 11 + app/src/main/res/drawable/person.xml | 9 + app/src/main/res/drawable/play.xml | 9 + app/src/main/res/drawable/playlist_add.xml | 9 + app/src/main/res/drawable/playlist_import.xml | 9 + app/src/main/res/drawable/playlist_play.xml | 9 + app/src/main/res/drawable/poeditor.xml | 43 + app/src/main/res/drawable/previewalbum.jpg | Bin 0 -> 44192 bytes app/src/main/res/drawable/queue_music.xml | 9 + app/src/main/res/drawable/radar.webp | Bin 0 -> 1448 bytes app/src/main/res/drawable/radio.xml | 27 + .../res/drawable/radio_button_checked.xml | 9 + .../res/drawable/radio_button_unchecked.xml | 9 + app/src/main/res/drawable/refresh.xml | 10 + app/src/main/res/drawable/remove.xml | 9 + app/src/main/res/drawable/repeat.xml | 9 + app/src/main/res/drawable/repeat_on.xml | 9 + app/src/main/res/drawable/repeat_one.xml | 9 + app/src/main/res/drawable/repeat_one_on.xml | 9 + app/src/main/res/drawable/replay.xml | 9 + app/src/main/res/drawable/resource_public.xml | 9 + app/src/main/res/drawable/restore.xml | 9 + app/src/main/res/drawable/robot.xml | 9 + .../main/res/drawable/rounded_image_bg.xml | 4 + app/src/main/res/drawable/scatter_plot.xml | 9 + app/src/main/res/drawable/schedule.xml | 9 + app/src/main/res/drawable/screenshot.xml | 9 + app/src/main/res/drawable/search.xml | 9 + app/src/main/res/drawable/search_off.xml | 9 + app/src/main/res/drawable/security.xml | 9 + app/src/main/res/drawable/select.xml | 9 + app/src/main/res/drawable/select_all.xml | 9 + app/src/main/res/drawable/settings.xml | 9 + app/src/main/res/drawable/settingsv2.xml | 9 + app/src/main/res/drawable/share.xml | 9 + app/src/main/res/drawable/shortcut_albums.xml | 9 + .../main/res/drawable/shortcut_explore.xml | 11 + .../main/res/drawable/shortcut_library.xml | 9 + .../main/res/drawable/shortcut_playlists.xml | 10 + app/src/main/res/drawable/shortcut_search.xml | 9 + app/src/main/res/drawable/shortcut_songs.xml | 9 + app/src/main/res/drawable/shuffle.xml | 9 + app/src/main/res/drawable/shuffle_on.xml | 9 + .../res/drawable/signal_cellular_nodata.xml | 9 + app/src/main/res/drawable/similar.xml | 9 + app/src/main/res/drawable/skip_next.xml | 9 + app/src/main/res/drawable/skip_previous.xml | 9 + app/src/main/res/drawable/sliders.xml | 9 + .../main/res/drawable/slow_motion_video.xml | 9 + app/src/main/res/drawable/speed.xml | 9 + app/src/main/res/drawable/storage.xml | 9 + app/src/main/res/drawable/subscribe.xml | 9 + app/src/main/res/drawable/subscribed.xml | 9 + app/src/main/res/drawable/support_agent.xml | 9 + app/src/main/res/drawable/swipe.xml | 16 + app/src/main/res/drawable/sync.xml | 9 + app/src/main/res/drawable/tab.xml | 9 + app/src/main/res/drawable/telegram.xml | 9 + app/src/main/res/drawable/tensorflow.xml | 15 + app/src/main/res/drawable/thumb_up.xml | 9 + app/src/main/res/drawable/token.xml | 9 + app/src/main/res/drawable/translate.xml | 9 + app/src/main/res/drawable/trending_up.xml | 9 + app/src/main/res/drawable/tune.xml | 9 + app/src/main/res/drawable/uncheck_box.xml | 9 + app/src/main/res/drawable/update.xml | 9 + app/src/main/res/drawable/verified_user.xml | 9 + app/src/main/res/drawable/visibility.xml | 9 + app/src/main/res/drawable/visibility_off.xml | 9 + app/src/main/res/drawable/volume_off.xml | 9 + app/src/main/res/drawable/volume_up.xml | 10 + app/src/main/res/drawable/waves.xml | 12 + .../main/res/drawable/widget_background.xml | 6 + app/src/main/res/drawable/widget_preview.png | Bin 0 -> 593089 bytes app/src/main/res/drawable/wifi_proxy.xml | 9 + app/src/main/res/font/great_vibes.ttf | Bin 0 -> 457588 bytes app/src/main/res/layout-v31/widget_music.xml | 201 + app/src/main/res/layout/widget_music.xml | 201 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5580 bytes .../mipmap-hdpi/ic_launcher_background.png | Bin 0 -> 378 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 7204 bytes .../mipmap-hdpi/ic_launcher_monochrome.png | Bin 0 -> 2140 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5580 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3226 bytes .../mipmap-mdpi/ic_launcher_background.png | Bin 0 -> 315 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 3815 bytes .../mipmap-mdpi/ic_launcher_monochrome.png | Bin 0 -> 1425 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3226 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8521 bytes .../mipmap-xhdpi/ic_launcher_background.png | Bin 0 -> 454 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 11453 bytes .../mipmap-xhdpi/ic_launcher_monochrome.png | Bin 0 -> 3027 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 8521 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 15064 bytes .../mipmap-xxhdpi/ic_launcher_background.png | Bin 0 -> 661 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 22340 bytes .../mipmap-xxhdpi/ic_launcher_monochrome.png | Bin 0 -> 5171 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 15064 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 23474 bytes .../mipmap-xxxhdpi/ic_launcher_background.png | Bin 0 -> 873 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 36973 bytes .../mipmap-xxxhdpi/ic_launcher_monochrome.png | Bin 0 -> 7356 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 23474 bytes app/src/main/res/values-DE/strings.xml | 560 + app/src/main/res/values-af-rZA/strings.xml | 477 + app/src/main/res/values-ar/strings.xml | 465 + app/src/main/res/values-be/strings.xml | 288 + app/src/main/res/values-bn-rIN/strings.xml | 270 + app/src/main/res/values-ca-rES/strings.xml | 561 + app/src/main/res/values-ca/strings.xml | 561 + app/src/main/res/values-cs/strings.xml | 323 + app/src/main/res/values-da-rDK/strings.xml | 401 + app/src/main/res/values-el-rGR/strings.xml | 372 + app/src/main/res/values-en-rUS/strings.xml | 430 + app/src/main/res/values-es/strings.xml | 558 + app/src/main/res/values-fa-rIR/strings.xml | 270 + app/src/main/res/values-fr-rFR/strings.xml | 572 + app/src/main/res/values-hi/strings.xml | 500 + app/src/main/res/values-hu/strings.xml | 444 + app/src/main/res/values-id/strings.xml | 471 + app/src/main/res/values-it/strings.xml | 444 + app/src/main/res/values-iw-rIL/strings.xml | 425 + app/src/main/res/values-ja-rJP/strings.xml | 548 + app/src/main/res/values-ko-rKR/strings.xml | 261 + app/src/main/res/values-ml/strings.xml | 560 + app/src/main/res/values-ne/strings.xml | 454 + app/src/main/res/values-nl/strings.xml | 270 + app/src/main/res/values-no-rNO/strings.xml | 401 + app/src/main/res/values-or-rIN/strings.xml | 270 + app/src/main/res/values-pa/strings.xml | 270 + app/src/main/res/values-pl/strings.xml | 589 + app/src/main/res/values-pt-rBR/strings.xml | 584 + app/src/main/res/values-ro-rRO/strings.xml | 413 + app/src/main/res/values-ru-rRU/strings.xml | 584 + app/src/main/res/values-sr-rSP/strings.xml | 413 + app/src/main/res/values-sv-rSE/strings.xml | 503 + app/src/main/res/values-tr-rTR/strings.xml | 401 + app/src/main/res/values-tr/strings.xml | 433 + app/src/main/res/values-uk-rUA/strings.xml | 468 + app/src/main/res/values-v31/styles.xml | 19 + app/src/main/res/values-vi-rVN/strings.xml | 442 + app/src/main/res/values-vi/strings.xml | 548 + app/src/main/res/values-zh-rCN/strings.xml | 467 + app/src/main/res/values/app_name.xml | 4 + app/src/main/res/values/colors.xml | 17 + app/src/main/res/values/dimens.xml | 39 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings.xml | 599 + app/src/main/res/values/styles.xml | 9 + app/src/main/res/values/values.xml | 8 + app/src/main/res/xml-v25/shortcuts.xml | 33 + app/src/main/res/xml-v31/widget_info.xml | 13 + app/src/main/res/xml/automotive_app_desc.xml | 4 + app/src/main/res/xml/backup_rules.xml | 12 + .../main/res/xml/data_extraction_rules.xml | 29 + app/src/main/res/xml/file_paths.xml | 24 + app/src/main/res/xml/widget_info.xml | 11 + assets/ic_launcher-playstore.png | Bin 0 -> 11052 bytes build.gradle.kts | 37 + crowdin.yml | 3 + .../android/en-US/full_description.txt | 21 + .../android/en-US/images/featureGraphic.png | Bin 0 -> 2295330 bytes .../metadata/android/en-US/images/icon.png | Bin 0 -> 17104 bytes .../images/phoneScreenshots/screenshot_1.png | Bin 0 -> 1137461 bytes .../images/phoneScreenshots/screenshot_10.png | Bin 0 -> 284856 bytes .../images/phoneScreenshots/screenshot_11.png | Bin 0 -> 366783 bytes .../images/phoneScreenshots/screenshot_12.png | Bin 0 -> 272313 bytes .../images/phoneScreenshots/screenshot_13.png | Bin 0 -> 314444 bytes .../images/phoneScreenshots/screenshot_14.png | Bin 0 -> 202188 bytes .../images/phoneScreenshots/screenshot_2.png | Bin 0 -> 988967 bytes .../images/phoneScreenshots/screenshot_3.png | Bin 0 -> 421498 bytes .../images/phoneScreenshots/screenshot_4.png | Bin 0 -> 364620 bytes .../images/phoneScreenshots/screenshot_5.png | Bin 0 -> 278371 bytes .../images/phoneScreenshots/screenshot_6.png | Bin 0 -> 300349 bytes .../images/phoneScreenshots/screenshot_7.png | Bin 0 -> 265297 bytes .../images/phoneScreenshots/screenshot_8.png | Bin 0 -> 361250 bytes .../images/phoneScreenshots/screenshot_9.png | Bin 0 -> 316561 bytes .../android/en-US/short_description.txt | 1 + fastlane/metadata/android/en-US/title.txt | 1 + .../android/es-ES/full_description.txt | 21 + .../metadata/android/es-ES/images/icon.png | Bin 0 -> 17104 bytes .../images/phoneScreenshots/screenshot_10.png | Bin 0 -> 284856 bytes .../images/phoneScreenshots/screenshot_11.png | Bin 0 -> 366783 bytes .../images/phoneScreenshots/screenshot_12.png | Bin 0 -> 272313 bytes .../images/phoneScreenshots/screenshot_13.png | Bin 0 -> 314444 bytes .../images/phoneScreenshots/screenshot_14.png | Bin 0 -> 202188 bytes .../images/phoneScreenshots/screenshot_2.png | Bin 0 -> 988967 bytes .../images/phoneScreenshots/screenshot_3.png | Bin 0 -> 421498 bytes .../images/phoneScreenshots/screenshot_4.png | Bin 0 -> 364620 bytes .../images/phoneScreenshots/screenshot_5.png | Bin 0 -> 278371 bytes .../images/phoneScreenshots/screenshot_6.png | Bin 0 -> 300349 bytes .../images/phoneScreenshots/screenshot_7.png | Bin 0 -> 265297 bytes .../images/phoneScreenshots/screenshot_8.png | Bin 0 -> 361250 bytes .../images/phoneScreenshots/screenshot_9.png | Bin 0 -> 316561 bytes .../android/es-ES/short_description.txt | 1 + fastlane/metadata/android/es-ES/title.txt | 1 + gradle.properties | 20 + gradle/libs.versions.toml | 122 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 + gradlew.bat | 85 + innertube/.gitignore | 1 + innertube/build.gradle.kts | 20 + .../airbeats/innertube/InnerTube.kt | 472 + .../darkxvenom/airbeats/innertube/YouTube.kt | 824 + .../airbeats/innertube/models/AccountInfo.kt | 7 + .../models/AutomixPreviewVideoRenderer.kt | 18 + .../airbeats/innertube/models/Badges.kt | 13 + .../airbeats/innertube/models/Button.kt | 16 + .../airbeats/innertube/models/Context.kt | 38 + .../airbeats/innertube/models/Continuation.kt | 20 + .../models/ContinuationItemRenderer.kt | 18 + .../airbeats/innertube/models/Endpoint.kt | 107 + .../airbeats/innertube/models/GridRenderer.kt | 26 + .../airbeats/innertube/models/Icon.kt | 8 + .../airbeats/innertube/models/Menu.kt | 51 + .../models/MusicCardShelfRenderer.kt | 30 + .../models/MusicCarouselShelfRenderer.kt | 31 + .../models/MusicDescriptionShelfRenderer.kt | 11 + .../models/MusicNavigationButtonRenderer.kt | 21 + .../models/MusicPlaylistShelfRenderer.kt | 10 + .../innertube/models/MusicQueueRenderer.kt | 25 + .../models/MusicResponsiveHeaderRenderer.kt | 24 + .../models/MusicResponsiveListItemRenderer.kt | 98 + .../innertube/models/MusicShelfRenderer.kt | 28 + .../models/MusicTwoRowItemRenderer.kt | 52 + .../innertube/models/NavigationEndpoint.kt | 26 + .../innertube/models/PlaylistDeleteBody.kt | 10 + .../innertube/models/PlaylistPanelRenderer.kt | 21 + .../models/PlaylistPanelVideoRenderer.kt | 19 + .../innertube/models/ResponseContext.kt | 21 + .../airbeats/innertube/models/Runs.kt | 43 + .../innertube/models/SearchSuggestions.kt | 6 + .../SearchSuggestionsSectionRenderer.kt | 20 + .../innertube/models/SectionListRenderer.kt | 52 + .../innertube/models/SubscriptionButton.kt | 14 + .../airbeats/innertube/models/Tabs.kt | 26 + .../innertube/models/ThumbnailRenderer.kt | 29 + .../airbeats/innertube/models/Thumbnails.kt | 15 + .../airbeats/innertube/models/YTItem.kt | 86 + .../innertube/models/YouTubeClient.kt | 106 + .../innertube/models/YouTubeLocale.kt | 9 + .../innertube/models/body/AccountMenuBody.kt | 11 + .../innertube/models/body/BrowseBody.kt | 13 + .../models/body/CreatePlaylistBody.kt | 18 + .../innertube/models/body/EditPlaylistBody.kt | 46 + .../innertube/models/body/GetQueueBody.kt | 11 + .../models/body/GetSearchSuggestionsBody.kt | 10 + .../models/body/GetTranscriptBody.kt | 10 + .../innertube/models/body/LikeBody.kt | 18 + .../innertube/models/body/NextBody.kt | 15 + .../innertube/models/body/PlayerBody.kt | 30 + .../innertube/models/body/SearchBody.kt | 11 + .../innertube/models/body/SubscribeBody.kt | 10 + .../models/response/AccountMenuResponse.kt | 49 + .../AddItemYouTubePlaylistResponse.kt | 20 + .../models/response/BrowseResponse.kt | 172 + .../models/response/ContinuationResponse.kt | 20 + .../models/response/CreatePlaylistResponse.kt | 8 + .../models/response/GetQueueResponse.kt | 14 + .../response/GetSearchSuggestionsResponse.kt | 14 + .../models/response/GetTranscriptResponse.kt | 65 + .../innertube/models/response/NextResponse.kt | 38 + .../models/response/PlayerResponse.kt | 104 + .../models/response/SearchResponse.kt | 33 + .../airbeats/innertube/pages/AlbumPage.kt | 124 + .../pages/ArtistItemsContinuationPage.kt | 8 + .../innertube/pages/ArtistItemsPage.kt | 107 + .../airbeats/innertube/pages/ArtistPage.kt | 172 + .../airbeats/innertube/pages/BrowseResult.kt | 31 + .../airbeats/innertube/pages/ExplorePage.kt | 8 + .../airbeats/innertube/pages/HistoryPage.kt | 62 + .../airbeats/innertube/pages/HomePage.kt | 134 + .../innertube/pages/LibraryAlbumsPage.kt | 36 + .../pages/LibraryContinuationPage.kt | 8 + .../airbeats/innertube/pages/LibraryPage.kt | 147 + .../airbeats/innertube/pages/MoodAndGenres.kt | 47 + .../airbeats/innertube/pages/NewPipe.kt | 103 + .../innertube/pages/NewReleaseAlbumPage.kt | 44 + .../airbeats/innertube/pages/NextPage.kt | 68 + .../airbeats/innertube/pages/PageHelper.kt | 25 + .../pages/PlaylistContinuationPage.kt | 8 + .../airbeats/innertube/pages/PlaylistPage.kt | 77 + .../airbeats/innertube/pages/RelatedPage.kt | 160 + .../airbeats/innertube/pages/SearchPage.kt | 195 + .../innertube/pages/SearchSuggestionPage.kt | 146 + .../innertube/pages/SearchSummaryPage.kt | 382 + .../airbeats/innertube/utils/Utils.kt | 174 + jossredconnect/.gitignore | 1 + jossredconnect/build.gradle.kts | 17 + .../airbeats/jossredconnect/JossRedClient.kt | 58 + kizzy/.gitignore | 1 + kizzy/build.gradle.kts | 18 + kizzy/src/main/AndroidManifest.xml | 4 + .../com/my/kizzy/gateway/DiscordWebSocket.kt | 258 + .../my/kizzy/gateway/entities/HeartBeat.kt | 22 + .../com/my/kizzy/gateway/entities/Identify.kt | 42 + .../com/my/kizzy/gateway/entities/Payload.kt | 18 + .../com/my/kizzy/gateway/entities/Ready.kt | 25 + .../com/my/kizzy/gateway/entities/Resume.kt | 14 + .../my/kizzy/gateway/entities/op/OpCode.kt | 42 + .../gateway/entities/op/OpCodesSerializer.kt | 22 + .../gateway/entities/presence/Activity.kt | 34 + .../kizzy/gateway/entities/presence/Assets.kt | 16 + .../gateway/entities/presence/Metadata.kt | 10 + .../gateway/entities/presence/Presence.kt | 16 + .../gateway/entities/presence/Timestamps.kt | 12 + .../kotlin/com/my/kizzy/remote/ApiResponse.kt | 10 + .../kotlin/com/my/kizzy/remote/ApiService.kt | 47 + .../my/kizzy/repository/KizzyRepository.kt | 27 + .../main/kotlin/com/my/kizzy/rpc/KizzyRPC.kt | 134 + .../main/kotlin/com/my/kizzy/rpc/RpcImage.kt | 34 + .../main/kotlin/com/my/kizzy/rpc/UserInfo.kt | 9 + .../src/main/kotlin/com/my/kizzy/utils/Ext.kt | 40 + kugou/.gitignore | 1 + kugou/build.gradle.kts | 18 + .../com/darkxvenom/airbeats/kugou/KuGou.kt | 179 + .../kugou/models/DownloadLyricsResponse.kt | 8 + .../airbeats/kugou/models/Keyword.kt | 3 + .../kugou/models/SearchLyricsResponse.kt | 23 + .../kugou/models/SearchSongResponse.kt | 22 + lint.xml | 6 + linux/appimage/AppRun | 4 + linux/build.sh | 23 + linux/opentube.desktop | 8 + lrclib/.gitignore | 1 + lrclib/build.gradle.kts | 19 + .../com/darkxvenom/airbeats/lrclib/LrcLib.kt | 125 + .../airbeats/lrclib/models/Track.kt | 16 + material-color-utilities/.gitignore | 1 + material-color-utilities/build.gradle.kts | 13 + .../java/com/google/material/color/LICENSE | 191 + .../google/material/color/blend/Blend.java | 94 + .../material/color/contrast/Contrast.java | 209 + .../color/dislike/DislikeAnalyzer.java | 59 + .../color/dynamiccolor/ContrastCurve.java | 83 + .../color/dynamiccolor/DynamicColor.java | 550 + .../color/dynamiccolor/DynamicScheme.java | 375 + .../dynamiccolor/MaterialDynamicColors.java | 982 + .../color/dynamiccolor/ToneDeltaPair.java | 111 + .../color/dynamiccolor/TonePolarity.java | 33 + .../material/color/dynamiccolor/Variant.java | 31 + .../com/google/material/color/hct/Cam16.java | 447 + .../com/google/material/color/hct/Hct.java | 157 + .../google/material/color/hct/HctSolver.java | 675 + .../material/color/hct/ViewingConditions.java | 207 + .../material/color/palettes/CorePalette.java | 86 + .../material/color/palettes/CorePalettes.java | 47 + .../material/color/palettes/TonalPalette.java | 190 + .../color/quantize/PointProvider.java | 38 + .../color/quantize/PointProviderLab.java | 58 + .../material/color/quantize/Quantizer.java | 21 + .../color/quantize/QuantizerCelebi.java | 58 + .../material/color/quantize/QuantizerMap.java | 43 + .../color/quantize/QuantizerResult.java | 30 + .../color/quantize/QuantizerWsmeans.java | 235 + .../material/color/quantize/QuantizerWu.java | 407 + .../google/material/color/scheme/Scheme.java | 824 + .../material/color/scheme/SchemeContent.java | 60 + .../color/scheme/SchemeExpressive.java | 51 + .../material/color/scheme/SchemeFidelity.java | 53 + .../color/scheme/SchemeFruitSalad.java | 42 + .../color/scheme/SchemeMonochrome.java | 40 + .../material/color/scheme/SchemeNeutral.java | 39 + .../material/color/scheme/SchemeRainbow.java | 41 + .../color/scheme/SchemeTonalSpot.java | 41 + .../material/color/scheme/SchemeVibrant.java | 45 + .../google/material/color/score/Score.java | 270 + .../color/temperature/TemperatureCache.java | 335 + .../material/color/utils/ColorUtils.java | 288 + .../material/color/utils/MathUtils.java | 141 + .../material/color/utils/StringUtils.java | 37 + settings.gradle.kts | 41 + 766 files changed, 128852 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug-report-.md create mode 100644 .gitignore create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.en.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/1.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/10.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/11.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/12.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/13.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/14.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/15.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/16.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/17.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/18.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/19.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/2.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/3.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/4.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/5.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/6.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/7.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/8.json create mode 100644 app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/9.json create mode 100644 app/src/debug/res/values/app_name.xml create mode 100644 app/src/debug/res/xml-v25/shortcuts.xml create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/assets/splash_anim.json create mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/java/com/darkxvenom/airbeats/App.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/MainActivity.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/MusicWidget.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/MyFirebaseMessagingService.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/constants/Dimensions.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/constants/HistorySource.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/constants/LibraryFilter.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/constants/MediaSessionConstants.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/constants/PreferenceKeys.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/constants/StatPeriod.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/Converters.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/DatabaseDao.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/MusicDatabase.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/Album.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumArtistMap.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumEntity.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumWithSongs.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/Artist.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/ArtistEntity.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/Event.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/EventWithSong.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/FormatEntity.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/LocalItem.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/LyricsEntity.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/PlayCountEntity.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/Playlist.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistEntity.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSong.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSongMap.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSongMapPreview.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/RelatedSongMap.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/SearchHistory.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/SetVideoIdEntity.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/Song.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/SongAlbumMap.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/SongArtistMap.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/SongEntity.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/SongWithStats.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/SortedSongAlbumMap.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/db/entities/SortedSongArtistMap.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/di/AppModule.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/di/LyricsHelperEntryPoint.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/di/PreferenceModule.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/extensions/ContextExt.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/extensions/CoroutineExt.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/extensions/FileExt.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/extensions/ListExt.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/extensions/MediaItemExt.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/extensions/PlayerExt.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/extensions/QueueExt.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/extensions/StringExt.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/extensions/UtilExt.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/lyrics/KuGouLyricsProvider.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/lyrics/LrcLibLyricsProvider.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsEntry.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsHelper.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsProvider.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/lyrics/YouTubeLyricsProvider.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/lyrics/YouTubeSubtitleLyricsProvider.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/models/ItemsPage.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/models/MediaMetadata.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/models/PersistPlayerState.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/models/PersistQueue.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/models/SimilarRecommendation.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/DownloadUtil.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/ExoDownloadService.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/MediaLibrarySessionCallback.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/MusicService.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/PlayerConnection.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/SleepTimer.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/queues/EmptyQueue.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/queues/ListQueue.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/queues/LocalAlbumRadio.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/queues/Queue.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/queues/YouTubeAlbumRadio.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/playback/queues/YouTubeQueue.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/AutoResizeText.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/AvatarDisplay.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/BigSeekBar.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheet.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheetMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheetPage.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/ChangelogButton.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/ChipsRow.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/CircleIconButton.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/CreatePlaylistDialog.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/CurvedBottomNavigationBar.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/CustomAvatarSelector.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/Dialog.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/EmptyPlaceholder.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/EnhancedPreferenceEntry.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/GridMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/HideOnScrollFAB.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/IconButton.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/Items.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/Library.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/LocaleManager.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/Lyrics.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/NamePreferenceManager.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/NameProvider.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/NameSetupDialog.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/NavigationTitle.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/PlayerSlider.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/PlayingIndicator.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/Preference.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/ReleaseNotesCard.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/SearchBar.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/SettingsCategory.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/SettingsPage.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/ShapeSelector.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/ShareLyrics.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/ShareLyricsDialog.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/SortHeader.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/ThumbnailCornerRadiusSelector.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/VerticalFastScroller.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ButtonPlaceholder.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/GridItemPlaceholder.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ListItemPlaceholder.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ShimmerHost.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/TextPlaceholder.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/AddToPlaylistDialog.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/AlbumMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/ArtistMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/ImportPlaylistDialog.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/LyricsMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/OnlinePlaylistAdder.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlayerMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlaylistMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/SelectionSongsMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/SongMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeAlbumMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeArtistMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubePlaylistMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeSongMenu.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/player/MiniPlayer.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/player/PlaybackError.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/player/Player.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/player/Queue.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/player/Thumbnail.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/AccountScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/AlbumScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/ExploreScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/HistoryScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/HomeScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/InsightScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/LoginScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/NavigationBuilder.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/NewReleaseScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/Screens.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/StatsScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/YouTubeBrowseScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistItemsScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistSongsScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/CachePlaylistScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryAlbumsScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryArtistsScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryMixScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryPlaylistsScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibrarySongsScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LocalSongsScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/AutoPlaylistScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/LocalPlaylistScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/OnlinePlaylistScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/TopPlaylistScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/LocalSearchScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/OnlineSearchResult.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/OnlineSearchScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AboutScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AccountSettings.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AppearanceSettings.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/BackupAndRestore.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/ContentSettings.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/DiscordLoginScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/DiscordSettings.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/PlayerSettings.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/PrivacySettings.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/StorageSettings.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/utils/AppBar.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/utils/FadingEdge.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/utils/ItemWrapper.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/utils/LazyGridSnapLayoutInfoProvider.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/utils/NavControllerUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/utils/ScrollUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/utils/ShapeUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/utils/StringUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/ui/utils/YouTubeUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/CoilBitmapLoader.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/ComposeDebugUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/DataStore.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/DiscordRPC.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/LyricsImageGenerator.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/NetworkConnectivityObserver.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/NetworkUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/PlaylistImageUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/ShapeUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/StringUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/SyncUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/Updater.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/Utils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/YTPlayerUtils.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/potoken/JavaScriptUtil.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenException.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenGenerator.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenResult.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenWebView.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/AccountViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/AlbumViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/ArtistItemsViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/ArtistViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/AutoPlaylistViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/BackupRestoreViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/ExploreViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/HistoryViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/HomeViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/LibraryViewModels.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalPlaylistViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSearchViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSong.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSongsViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/LyricsMenuViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/MoodAndGenresViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/NewReleaseViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlinePlaylistViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlineSearchSuggestionViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlineSearchViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/StatsViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/TopPlaylistViewModel.kt create mode 100644 app/src/main/java/com/darkxvenom/airbeats/viewmodels/YouTubeBrowseViewModel.kt create mode 100644 app/src/main/logoxd-playstore.png create mode 100644 app/src/main/res/drawable/account.xml create mode 100644 app/src/main/res/drawable/add.xml create mode 100644 app/src/main/res/drawable/airbeats.xml create mode 100644 app/src/main/res/drawable/airbeats_logo.xml create mode 100644 app/src/main/res/drawable/airbeats_monochrome.xml create mode 100644 app/src/main/res/drawable/album.xml create mode 100644 app/src/main/res/drawable/apps.xml create mode 100644 app/src/main/res/drawable/arrow_back.xml create mode 100644 app/src/main/res/drawable/arrow_downward.xml create mode 100644 app/src/main/res/drawable/arrow_forward.xml create mode 100644 app/src/main/res/drawable/arrow_top_left.xml create mode 100644 app/src/main/res/drawable/arrow_upward.xml create mode 100644 app/src/main/res/drawable/artist.xml create mode 100644 app/src/main/res/drawable/attach_money.xml create mode 100644 app/src/main/res/drawable/auto_awesome.xml create mode 100644 app/src/main/res/drawable/backup.xml create mode 100644 app/src/main/res/drawable/bedtime.xml create mode 100644 app/src/main/res/drawable/block.xml create mode 100644 app/src/main/res/drawable/bookmark.xml create mode 100644 app/src/main/res/drawable/bookmark_filled.xml create mode 100644 app/src/main/res/drawable/bug_report.xml create mode 100644 app/src/main/res/drawable/cached.xml create mode 100644 app/src/main/res/drawable/car.xml create mode 100644 app/src/main/res/drawable/casino.xml create mode 100644 app/src/main/res/drawable/check.xml create mode 100644 app/src/main/res/drawable/check_box.xml create mode 100644 app/src/main/res/drawable/check_circle.xml create mode 100644 app/src/main/res/drawable/chevron_right.xml create mode 100644 app/src/main/res/drawable/clear_all.xml create mode 100644 app/src/main/res/drawable/close.xml create mode 100644 app/src/main/res/drawable/cloud_download.xml create mode 100644 app/src/main/res/drawable/cloud_lock.xml create mode 100644 app/src/main/res/drawable/codigo.xml create mode 100644 app/src/main/res/drawable/compare_arrows.xml create mode 100644 app/src/main/res/drawable/content_copy.xml create mode 100644 app/src/main/res/drawable/contrast.xml create mode 100644 app/src/main/res/drawable/dark_mode.xml create mode 100644 app/src/main/res/drawable/delete.xml create mode 100644 app/src/main/res/drawable/delete_history.xml create mode 100644 app/src/main/res/drawable/deployed_code_update.xml create mode 100644 app/src/main/res/drawable/deselect.xml create mode 100644 app/src/main/res/drawable/desktop_windows.xml create mode 100644 app/src/main/res/drawable/discord.xml create mode 100644 app/src/main/res/drawable/discover_tune.xml create mode 100644 app/src/main/res/drawable/donate.xml create mode 100644 app/src/main/res/drawable/done.xml create mode 100644 app/src/main/res/drawable/double_arrow.xml create mode 100644 app/src/main/res/drawable/download.xml create mode 100644 app/src/main/res/drawable/drag_handle.xml create mode 100644 app/src/main/res/drawable/edit.xml create mode 100644 app/src/main/res/drawable/email.xml create mode 100644 app/src/main/res/drawable/empty_splash.xml create mode 100644 app/src/main/res/drawable/empty_state.xml create mode 100644 app/src/main/res/drawable/equalizer.xml create mode 100644 app/src/main/res/drawable/error.xml create mode 100644 app/src/main/res/drawable/expand_less.xml create mode 100644 app/src/main/res/drawable/expand_more.xml create mode 100644 app/src/main/res/drawable/explicit.xml create mode 100644 app/src/main/res/drawable/explore.xml create mode 100644 app/src/main/res/drawable/explore_filled.xml create mode 100644 app/src/main/res/drawable/explore_outlined.xml create mode 100644 app/src/main/res/drawable/extension.xml create mode 100644 app/src/main/res/drawable/facebook.xml create mode 100644 app/src/main/res/drawable/fast_forward.xml create mode 100644 app/src/main/res/drawable/favorite.xml create mode 100644 app/src/main/res/drawable/favorite_border.xml create mode 100644 app/src/main/res/drawable/feedback.xml create mode 100644 app/src/main/res/drawable/folder.xml create mode 100644 app/src/main/res/drawable/format_align_center.xml create mode 100644 app/src/main/res/drawable/format_align_left.xml create mode 100644 app/src/main/res/drawable/format_color.xml create mode 100644 app/src/main/res/drawable/fullscreen.xml create mode 100644 app/src/main/res/drawable/fullscreen_exit.xml create mode 100644 app/src/main/res/drawable/funbeta.xml create mode 100644 app/src/main/res/drawable/github.xml create mode 100644 app/src/main/res/drawable/google.xml create mode 100644 app/src/main/res/drawable/gradient.xml create mode 100644 app/src/main/res/drawable/graphic_eq.xml create mode 100644 app/src/main/res/drawable/grid_view.xml create mode 100644 app/src/main/res/drawable/group.xml create mode 100644 app/src/main/res/drawable/help.xml create mode 100644 app/src/main/res/drawable/history.xml create mode 100644 app/src/main/res/drawable/home.xml create mode 100644 app/src/main/res/drawable/home_filled.xml create mode 100644 app/src/main/res/drawable/home_outlined.xml create mode 100644 app/src/main/res/drawable/ia_icon.xml create mode 100644 app/src/main/res/drawable/image.xml create mode 100644 app/src/main/res/drawable/info.xml create mode 100644 app/src/main/res/drawable/input.xml create mode 100644 app/src/main/res/drawable/instagram.xml create mode 100644 app/src/main/res/drawable/language.xml create mode 100644 app/src/main/res/drawable/liberapay.xml create mode 100644 app/src/main/res/drawable/library_add.xml create mode 100644 app/src/main/res/drawable/library_add_check.xml create mode 100644 app/src/main/res/drawable/library_music.xml create mode 100644 app/src/main/res/drawable/library_music_filled.xml create mode 100644 app/src/main/res/drawable/library_music_outlined.xml create mode 100644 app/src/main/res/drawable/license.xml create mode 100644 app/src/main/res/drawable/lightbulb.xml create mode 100644 app/src/main/res/drawable/line_curve.xml create mode 100644 app/src/main/res/drawable/link.xml create mode 100644 app/src/main/res/drawable/list.xml create mode 100644 app/src/main/res/drawable/location_on.xml create mode 100644 app/src/main/res/drawable/lock.xml create mode 100644 app/src/main/res/drawable/lock_open.xml create mode 100644 app/src/main/res/drawable/login.xml create mode 100644 app/src/main/res/drawable/logout.xml create mode 100644 app/src/main/res/drawable/lyrics.xml create mode 100644 app/src/main/res/drawable/manage_search.xml create mode 100644 app/src/main/res/drawable/menu.xml create mode 100644 app/src/main/res/drawable/menu_book.xml create mode 100644 app/src/main/res/drawable/mood.xml create mode 100644 app/src/main/res/drawable/more_horiz.xml create mode 100644 app/src/main/res/drawable/more_vert.xml create mode 100644 app/src/main/res/drawable/music_note.xml create mode 100644 app/src/main/res/drawable/nav_bar.xml create mode 100644 app/src/main/res/drawable/navigate_next.xml create mode 100644 app/src/main/res/drawable/notification_off.xml create mode 100644 app/src/main/res/drawable/notification_on.xml create mode 100644 app/src/main/res/drawable/offline.xml create mode 100644 app/src/main/res/drawable/open_in_new.xml create mode 100644 app/src/main/res/drawable/palette.xml create mode 100644 app/src/main/res/drawable/pause.xml create mode 100644 app/src/main/res/drawable/paypal.xml create mode 100644 app/src/main/res/drawable/person.xml create mode 100644 app/src/main/res/drawable/play.xml create mode 100644 app/src/main/res/drawable/playlist_add.xml create mode 100644 app/src/main/res/drawable/playlist_import.xml create mode 100644 app/src/main/res/drawable/playlist_play.xml create mode 100644 app/src/main/res/drawable/poeditor.xml create mode 100644 app/src/main/res/drawable/previewalbum.jpg create mode 100644 app/src/main/res/drawable/queue_music.xml create mode 100644 app/src/main/res/drawable/radar.webp create mode 100644 app/src/main/res/drawable/radio.xml create mode 100644 app/src/main/res/drawable/radio_button_checked.xml create mode 100644 app/src/main/res/drawable/radio_button_unchecked.xml create mode 100644 app/src/main/res/drawable/refresh.xml create mode 100644 app/src/main/res/drawable/remove.xml create mode 100644 app/src/main/res/drawable/repeat.xml create mode 100644 app/src/main/res/drawable/repeat_on.xml create mode 100644 app/src/main/res/drawable/repeat_one.xml create mode 100644 app/src/main/res/drawable/repeat_one_on.xml create mode 100644 app/src/main/res/drawable/replay.xml create mode 100644 app/src/main/res/drawable/resource_public.xml create mode 100644 app/src/main/res/drawable/restore.xml create mode 100644 app/src/main/res/drawable/robot.xml create mode 100644 app/src/main/res/drawable/rounded_image_bg.xml create mode 100644 app/src/main/res/drawable/scatter_plot.xml create mode 100644 app/src/main/res/drawable/schedule.xml create mode 100644 app/src/main/res/drawable/screenshot.xml create mode 100644 app/src/main/res/drawable/search.xml create mode 100644 app/src/main/res/drawable/search_off.xml create mode 100644 app/src/main/res/drawable/security.xml create mode 100644 app/src/main/res/drawable/select.xml create mode 100644 app/src/main/res/drawable/select_all.xml create mode 100644 app/src/main/res/drawable/settings.xml create mode 100644 app/src/main/res/drawable/settingsv2.xml create mode 100644 app/src/main/res/drawable/share.xml create mode 100644 app/src/main/res/drawable/shortcut_albums.xml create mode 100644 app/src/main/res/drawable/shortcut_explore.xml create mode 100644 app/src/main/res/drawable/shortcut_library.xml create mode 100644 app/src/main/res/drawable/shortcut_playlists.xml create mode 100644 app/src/main/res/drawable/shortcut_search.xml create mode 100644 app/src/main/res/drawable/shortcut_songs.xml create mode 100644 app/src/main/res/drawable/shuffle.xml create mode 100644 app/src/main/res/drawable/shuffle_on.xml create mode 100644 app/src/main/res/drawable/signal_cellular_nodata.xml create mode 100644 app/src/main/res/drawable/similar.xml create mode 100644 app/src/main/res/drawable/skip_next.xml create mode 100644 app/src/main/res/drawable/skip_previous.xml create mode 100644 app/src/main/res/drawable/sliders.xml create mode 100644 app/src/main/res/drawable/slow_motion_video.xml create mode 100644 app/src/main/res/drawable/speed.xml create mode 100644 app/src/main/res/drawable/storage.xml create mode 100644 app/src/main/res/drawable/subscribe.xml create mode 100644 app/src/main/res/drawable/subscribed.xml create mode 100644 app/src/main/res/drawable/support_agent.xml create mode 100644 app/src/main/res/drawable/swipe.xml create mode 100644 app/src/main/res/drawable/sync.xml create mode 100644 app/src/main/res/drawable/tab.xml create mode 100644 app/src/main/res/drawable/telegram.xml create mode 100644 app/src/main/res/drawable/tensorflow.xml create mode 100644 app/src/main/res/drawable/thumb_up.xml create mode 100644 app/src/main/res/drawable/token.xml create mode 100644 app/src/main/res/drawable/translate.xml create mode 100644 app/src/main/res/drawable/trending_up.xml create mode 100644 app/src/main/res/drawable/tune.xml create mode 100644 app/src/main/res/drawable/uncheck_box.xml create mode 100644 app/src/main/res/drawable/update.xml create mode 100644 app/src/main/res/drawable/verified_user.xml create mode 100644 app/src/main/res/drawable/visibility.xml create mode 100644 app/src/main/res/drawable/visibility_off.xml create mode 100644 app/src/main/res/drawable/volume_off.xml create mode 100644 app/src/main/res/drawable/volume_up.xml create mode 100644 app/src/main/res/drawable/waves.xml create mode 100644 app/src/main/res/drawable/widget_background.xml create mode 100644 app/src/main/res/drawable/widget_preview.png create mode 100644 app/src/main/res/drawable/wifi_proxy.xml create mode 100644 app/src/main/res/font/great_vibes.ttf create mode 100644 app/src/main/res/layout-v31/widget_music.xml create mode 100644 app/src/main/res/layout/widget_music.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values-DE/strings.xml create mode 100644 app/src/main/res/values-af-rZA/strings.xml create mode 100644 app/src/main/res/values-ar/strings.xml create mode 100644 app/src/main/res/values-be/strings.xml create mode 100644 app/src/main/res/values-bn-rIN/strings.xml create mode 100644 app/src/main/res/values-ca-rES/strings.xml create mode 100644 app/src/main/res/values-ca/strings.xml create mode 100644 app/src/main/res/values-cs/strings.xml create mode 100644 app/src/main/res/values-da-rDK/strings.xml create mode 100644 app/src/main/res/values-el-rGR/strings.xml create mode 100644 app/src/main/res/values-en-rUS/strings.xml create mode 100644 app/src/main/res/values-es/strings.xml create mode 100644 app/src/main/res/values-fa-rIR/strings.xml create mode 100644 app/src/main/res/values-fr-rFR/strings.xml create mode 100644 app/src/main/res/values-hi/strings.xml create mode 100644 app/src/main/res/values-hu/strings.xml create mode 100644 app/src/main/res/values-id/strings.xml create mode 100644 app/src/main/res/values-it/strings.xml create mode 100644 app/src/main/res/values-iw-rIL/strings.xml create mode 100644 app/src/main/res/values-ja-rJP/strings.xml create mode 100644 app/src/main/res/values-ko-rKR/strings.xml create mode 100644 app/src/main/res/values-ml/strings.xml create mode 100644 app/src/main/res/values-ne/strings.xml create mode 100644 app/src/main/res/values-nl/strings.xml create mode 100644 app/src/main/res/values-no-rNO/strings.xml create mode 100644 app/src/main/res/values-or-rIN/strings.xml create mode 100644 app/src/main/res/values-pa/strings.xml create mode 100644 app/src/main/res/values-pl/strings.xml create mode 100644 app/src/main/res/values-pt-rBR/strings.xml create mode 100644 app/src/main/res/values-ro-rRO/strings.xml create mode 100644 app/src/main/res/values-ru-rRU/strings.xml create mode 100644 app/src/main/res/values-sr-rSP/strings.xml create mode 100644 app/src/main/res/values-sv-rSE/strings.xml create mode 100644 app/src/main/res/values-tr-rTR/strings.xml create mode 100644 app/src/main/res/values-tr/strings.xml create mode 100644 app/src/main/res/values-uk-rUA/strings.xml create mode 100644 app/src/main/res/values-v31/styles.xml create mode 100644 app/src/main/res/values-vi-rVN/strings.xml create mode 100644 app/src/main/res/values-vi/strings.xml create mode 100644 app/src/main/res/values-zh-rCN/strings.xml create mode 100644 app/src/main/res/values/app_name.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/values/values.xml create mode 100644 app/src/main/res/xml-v25/shortcuts.xml create mode 100644 app/src/main/res/xml-v31/widget_info.xml create mode 100644 app/src/main/res/xml/automotive_app_desc.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/main/res/xml/widget_info.xml create mode 100644 assets/ic_launcher-playstore.png create mode 100644 build.gradle.kts create mode 100644 crowdin.yml create mode 100644 fastlane/metadata/android/en-US/full_description.txt create mode 100644 fastlane/metadata/android/en-US/images/featureGraphic.png create mode 100644 fastlane/metadata/android/en-US/images/icon.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_1.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_10.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_11.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_12.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_13.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_14.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_2.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_3.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_4.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_5.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_6.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_7.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_8.png create mode 100644 fastlane/metadata/android/en-US/images/phoneScreenshots/screenshot_9.png create mode 100644 fastlane/metadata/android/en-US/short_description.txt create mode 100644 fastlane/metadata/android/en-US/title.txt create mode 100644 fastlane/metadata/android/es-ES/full_description.txt create mode 100644 fastlane/metadata/android/es-ES/images/icon.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_10.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_11.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_12.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_13.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_14.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_2.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_3.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_4.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_5.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_6.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_7.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_8.png create mode 100644 fastlane/metadata/android/es-ES/images/phoneScreenshots/screenshot_9.png create mode 100644 fastlane/metadata/android/es-ES/short_description.txt create mode 100644 fastlane/metadata/android/es-ES/title.txt create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 innertube/.gitignore create mode 100644 innertube/build.gradle.kts create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/InnerTube.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/YouTube.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/AccountInfo.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/AutomixPreviewVideoRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Badges.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Button.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Context.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Continuation.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/ContinuationItemRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Endpoint.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/GridRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Icon.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Menu.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicCardShelfRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicCarouselShelfRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicDescriptionShelfRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicNavigationButtonRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicPlaylistShelfRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicQueueRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicResponsiveHeaderRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicResponsiveListItemRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicShelfRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/MusicTwoRowItemRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/NavigationEndpoint.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/PlaylistDeleteBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/PlaylistPanelRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/PlaylistPanelVideoRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/ResponseContext.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Runs.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/SearchSuggestions.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/SearchSuggestionsSectionRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/SectionListRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/SubscriptionButton.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Tabs.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/ThumbnailRenderer.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/Thumbnails.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/YTItem.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/YouTubeClient.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/YouTubeLocale.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/AccountMenuBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/BrowseBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/CreatePlaylistBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/EditPlaylistBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/GetQueueBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/GetSearchSuggestionsBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/GetTranscriptBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/LikeBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/NextBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/PlayerBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/SearchBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/body/SubscribeBody.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/AccountMenuResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/AddItemYouTubePlaylistResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/BrowseResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/ContinuationResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/CreatePlaylistResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/GetQueueResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/GetSearchSuggestionsResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/GetTranscriptResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/NextResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/PlayerResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/models/response/SearchResponse.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/AlbumPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/ArtistItemsContinuationPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/ArtistItemsPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/ArtistPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/BrowseResult.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/ExplorePage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/HistoryPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/HomePage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/LibraryAlbumsPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/LibraryContinuationPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/LibraryPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/MoodAndGenres.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/NewPipe.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/NewReleaseAlbumPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/NextPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/PageHelper.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/PlaylistContinuationPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/PlaylistPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/RelatedPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/SearchPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/SearchSuggestionPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/pages/SearchSummaryPage.kt create mode 100644 innertube/src/main/java/com/darkxvenom/airbeats/innertube/utils/Utils.kt create mode 100644 jossredconnect/.gitignore create mode 100644 jossredconnect/build.gradle.kts create mode 100644 jossredconnect/src/main/kotlin/com/darkxvenom/airbeats/jossredconnect/JossRedClient.kt create mode 100644 kizzy/.gitignore create mode 100644 kizzy/build.gradle.kts create mode 100644 kizzy/src/main/AndroidManifest.xml create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/DiscordWebSocket.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/HeartBeat.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/Identify.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/Payload.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/Ready.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/Resume.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/op/OpCode.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/op/OpCodesSerializer.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/presence/Activity.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/presence/Assets.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/presence/Metadata.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/presence/Presence.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/gateway/entities/presence/Timestamps.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/remote/ApiResponse.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/remote/ApiService.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/repository/KizzyRepository.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/rpc/KizzyRPC.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/rpc/RpcImage.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/rpc/UserInfo.kt create mode 100644 kizzy/src/main/kotlin/com/my/kizzy/utils/Ext.kt create mode 100644 kugou/.gitignore create mode 100644 kugou/build.gradle.kts create mode 100644 kugou/src/main/java/com/darkxvenom/airbeats/kugou/KuGou.kt create mode 100644 kugou/src/main/java/com/darkxvenom/airbeats/kugou/models/DownloadLyricsResponse.kt create mode 100644 kugou/src/main/java/com/darkxvenom/airbeats/kugou/models/Keyword.kt create mode 100644 kugou/src/main/java/com/darkxvenom/airbeats/kugou/models/SearchLyricsResponse.kt create mode 100644 kugou/src/main/java/com/darkxvenom/airbeats/kugou/models/SearchSongResponse.kt create mode 100644 lint.xml create mode 100644 linux/appimage/AppRun create mode 100644 linux/build.sh create mode 100644 linux/opentube.desktop create mode 100644 lrclib/.gitignore create mode 100644 lrclib/build.gradle.kts create mode 100644 lrclib/src/main/java/com/darkxvenom/airbeats/lrclib/LrcLib.kt create mode 100644 lrclib/src/main/java/com/darkxvenom/airbeats/lrclib/models/Track.kt create mode 100644 material-color-utilities/.gitignore create mode 100644 material-color-utilities/build.gradle.kts create mode 100644 material-color-utilities/src/main/java/com/google/material/color/LICENSE create mode 100644 material-color-utilities/src/main/java/com/google/material/color/blend/Blend.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/contrast/Contrast.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/dislike/DislikeAnalyzer.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/dynamiccolor/ContrastCurve.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/dynamiccolor/DynamicColor.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/dynamiccolor/DynamicScheme.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/dynamiccolor/MaterialDynamicColors.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/dynamiccolor/ToneDeltaPair.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/dynamiccolor/TonePolarity.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/dynamiccolor/Variant.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/hct/Cam16.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/hct/Hct.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/hct/HctSolver.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/hct/ViewingConditions.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/palettes/CorePalette.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/palettes/CorePalettes.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/palettes/TonalPalette.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/quantize/PointProvider.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/quantize/PointProviderLab.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/quantize/Quantizer.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/quantize/QuantizerCelebi.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/quantize/QuantizerMap.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/quantize/QuantizerResult.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/quantize/QuantizerWsmeans.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/quantize/QuantizerWu.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/Scheme.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/SchemeContent.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/SchemeExpressive.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/SchemeFidelity.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/SchemeFruitSalad.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/SchemeMonochrome.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/SchemeNeutral.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/SchemeRainbow.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/SchemeTonalSpot.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/scheme/SchemeVibrant.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/score/Score.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/temperature/TemperatureCache.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/utils/ColorUtils.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/utils/MathUtils.java create mode 100644 material-color-utilities/src/main/java/com/google/material/color/utils/StringUtils.java create mode 100644 settings.gradle.kts diff --git a/.github/ISSUE_TEMPLATE/bug-report-.md b/.github/ISSUE_TEMPLATE/bug-report-.md new file mode 100644 index 00000000..a24aef7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report-.md @@ -0,0 +1,72 @@ +--- +name: Bug Report +about: Report a bug found in AirBeats to help us improve the application +title: "[BUG] Brief description of the issue" +labels: bug +assignees: darkxvenom +--- + +## Bug Description + + +## Steps to Reproduce + +1. Go to ' ' +2. Click on ' ' +3. Scroll down to ' ' +4. See error + +## Expected Behavior + + +## Actual Behavior + + +## System Information +- **AirBeats Version:** +- **Android Version:** +- **Device Model:** +- **Device Architecture:** +- **Available RAM:** +- **Available Storage:** + +## Frequency and Impact +- **How often does this occur?** +- **Severity:** +- **Impact:** + +## Environment Details +- **Installation Method:** +- **First occurrence:** +- **Network Connection:** + +## Evidence + + + +## Logcat Output (Optional but Recommended) + +``` +Paste logcat output here +``` +**How to capture logs:** +- Use `adb logcat | grep -i AirBeats` for filtered logs +- Or use `adb logcat > logcat.txt` for full logs + +## Additional Context + +- Is this a regression? (worked in previous version) +- Any recent system updates or changes? +- Other apps affected? +- Workaround found? + +## Checklist + +- [ ] I have searched for existing issues before creating this report +- [ ] I have provided all the requested information above +- [ ] I can consistently reproduce this issue +- [ ] I have tested on the latest version of AirBeats + +--- + +**Thank you for your contribution!** We will review this report as soon as possible. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..26efd70f --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +.DS_Store +/app/release/output-metadata.json + +.kotlin +app/release +output-metadata.json +/app/google-services.json diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..c2de9ad6 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,49 @@ +# Código de Conducta + +## Nuestro Compromiso + +En el interés de fomentar un ambiente abierto y acogedor, nosotros como colaboradores y mantenedores nos comprometemos a hacer de la participación en nuestro proyecto y nuestra comunidad una experiencia libre de acoso para todos, independientemente de la edad, dimensión corporal, discapacidad, etnia, identidad y expresión de género, nivel de experiencia, nacionalidad, apariencia personal, raza, religión, o identidad y orientación sexual. + +## Nuestros Estándares + +Ejemplos de comportamiento que contribuyen a crear un ambiente positivo: + +* Uso de lenguaje amable e inclusivo +* Respeto a diferentes puntos de vista y experiencias +* Aceptación de críticas constructivas +* Enfocarse en lo que es mejor para la comunidad +* Mostrar empatía hacia otros miembros de la comunidad + +Ejemplos de comportamiento inaceptable: + +* Uso de lenguaje o imágenes sexuales y atención o insinuaciones sexuales no deseadas +* Comentarios despectivos (_trolling_), insultantes o derogatorios, y ataques personales o políticos +* Acoso público o privado +* Publicación de información privada de terceros sin su consentimiento, como direcciones físicas o electrónicas +* Conducta que podría ser considerada inapropiada en un entorno profesional + +## Nuestras Responsabilidades + +Los mantenedores del proyecto son responsables de clarificar los estándares de comportamiento aceptable y se espera que tomen medidas correctivas y apropiadas en respuesta a situaciones de conducta inaceptable. + +Los mantenedores del proyecto tienen el derecho y la responsabilidad de eliminar, editar o rechazar comentarios, _commits_, código, ediciones de documentación, _issues_, y otras contribuciones que no estén alineadas con este Código de Conducta, o de prohibir temporal o permanentemente a cualquier colaborador cuyo comportamiento sea inapropiado, amenazante, ofensivo o perjudicial. + +## Alcance + +Este código de conducta aplica tanto a espacios del proyecto como a espacios públicos donde un individuo esté en representación del proyecto o comunidad. Ejemplos de esto incluyen el uso de la cuenta oficial de correo electrónico, publicaciones a través de las redes sociales oficiales, o presentaciones con personas designadas en eventos en línea o no. La representación del proyecto puede ser clarificada explícitamente por los mantenedores del proyecto. + +## Aplicación + +Ejemplos de abuso, acoso u otro tipo de comportamiento inaceptable pueden ser reportados al equipo del proyecto en [cervantesdarkxvenom@gmail.com](mailto:cervantesdarkxvenom@gmail.com). Todas las quejas serán revisadas e investigadas, generando un resultado apropiado a las circunstancias. El equipo del proyecto está obligado a mantener la confidencialidad de la persona que reporta el incidente. Detalles específicos acerca de las políticas de aplicación pueden ser publicadas por separado. + +Los mantenedores del proyecto que no sigan o que no hagan cumplir este Código de Conducta pueden ser eliminados de forma temporal o permanente del equipo del proyecto. + +## Atribución + +Este Código de Conducta es una adaptación del [Contributor Covenant][homepage], versión 2.0, +disponible en https://www.contributor-covenant.org/es/version/2/0/code_of_conduct.html + +[homepage]: https://www.contributor-covenant.org + +Para respuestas a las preguntas frecuentes acerca de este código de conducta, consulta las FAQ en +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..003a72cc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,234 @@ + + +# Contribution Guide + +Thank you for your interest in contributing to **AirBeats**! This document outlines the guidelines and best practices for contributing to the project. By following these recommendations, you help us maintain high-quality code and a collaborative, inclusive community. + +> \[!NOTE] +> Before you begin, make sure to read and understand our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Table of Contents + +* [How to Contribute](#how-to-contribute) + + * [Reporting Bugs](#reporting-bugs) + * [Suggesting Features](#suggesting-features) + * [Submitting Pull Requests](#submitting-pull-requests) +* [Style Guide](#style-guide) + + * [Code Style](#code-style) + * [Commit Messages](#commit-messages) + * [Documentation](#documentation) +* [Development Process](#development-process) + + * [Git Workflow](#git-workflow) + * [Pull Request Lifecycle](#pull-request-lifecycle) +* [Development Environment Setup](#development-environment-setup) +* [Translation Contributions](#translation-contributions) +* [Design Contributions](#design-contributions) + +--- + +## How to Contribute + +### Reporting Bugs + +We track bugs using [GitHub Issues](https://github.com/darkxvenom/airbeats/issues). Before creating a new issue, please search existing issues to see if the problem has already been reported. If you find a similar issue, feel free to add relevant information or insights as a comment. + +When creating a new issue, include: + +* **Descriptive title** that summarizes the issue +* **Steps to reproduce** the problem +* **Expected behavior** vs. **actual behavior** +* **Environment details** (e.g., app version, device model, Android version) +* **Screenshots** if applicable +* **Relevant logs** if available + +> \[!TIP] +> Use the provided templates to ensure you include all the necessary details. + +--- + +### Suggesting Features + +We also handle feature suggestions through [GitHub Issues](https://github.com/darkxvenom/airbeats/issues). When suggesting a new feature: + +* **Describe the problem** the feature is intended to solve +* **Explain the solution** and how it would work +* **Provide examples** or use cases if possible +* **Assess the scope** of the feature: is it minor, moderate, or large in terms of implementation effort? + +> \[!IMPORTANT] +> Please discuss the feature in an issue and obtain approval from the maintainers before starting development. + +--- + +### Submitting Pull Requests + +Pull Requests (PRs) are the primary way to contribute code to AirBeats. To ensure your PR is effective and easy to review, follow these steps: + +1. **Fork the repository** and create a feature branch from `main`. +2. **Implement your changes** while following the [style guide](#style-guide). +3. **Write or update tests** as needed to cover your changes. +4. **Ensure all tests pass** before submitting your PR. +5. **Update the documentation** if your changes affect the public API or usage instructions. +6. **Submit your pull request** with a **clear, concise, and informative description**. + +Your PR description should include: + +* **Purpose**: What problem does this PR solve? What feature or fix does it introduce? +* **Motivation**: Why is this change necessary or useful? How does it improve the project? +* **Implementation details**: Briefly explain how the changes were implemented. +* **Related issues**: Link to any related issues using `Closes #123`, `Fixes #456`, etc. + +Example: + +``` +### Summary +This PR adds offline playback support for downloaded audio files. + +### Motivation +This feature improves the user experience for those who want to listen to music without an internet connection. + +### Changes +- Added local media cache management +- Updated playback logic to prefer local files when available +- Modified UI to show offline availability + +### Related Issues +Closes #45 +``` + +> \[!TIP] +> Well-written PRs that clearly explain their purpose and impact are more likely to be reviewed and merged quickly. + +--- + +## Style Guide + +### Code Style + +* **Kotlin**: Follow the [official Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) +* **XML**: Use 4 spaces for indentation +* **Variable and function names**: Use `camelCase` (e.g., `playbackController`) +* **Class names**: Use `PascalCase` (e.g., `AudioPlayer`) +* **Constants**: Use `UPPER_SNAKE_CASE` (e.g., `MAX_VOLUME_LEVEL`) + +--- + +### Commit Messages + +We use [Conventional Commits](https://www.conventionalcommits.org/) to keep commit history clean and meaningful: + +``` +(): + +[optional body] + +[optional footer] +``` + +**Common types**: + +* `feat`: A new feature +* `fix`: A bug fix +* `docs`: Documentation-only changes +* `style`: Changes that do not affect functionality (formatting, whitespace, etc.) +* `refactor`: Code changes that neither fix a bug nor add a feature +* `perf`: Code changes that improve performance +* `test`: Adding or fixing tests +* `chore`: Changes to build scripts or auxiliary tools + +**Examples**: + +``` +feat(player): add support for offline playback +fix(ui): resolve layout bug in song list +docs(readme): update installation instructions +``` + +--- + +### Documentation + +* Write documentation in **Markdown** +* Document all public classes and functions +* Include **usage examples** whenever possible +* Keep the documentation **up to date** with code changes + +--- + +## Development Process + +### Git Workflow + +We use a structured branching model: + +* `main`: The production-ready branch +* `develop`: The active development branch +* `feature/xyz`: New feature branches +* `fix/xyz`: Bug fix branches +* `release/xyz`: Release preparation branches + +--- + +### Pull Request Lifecycle + +1. **Creation**: A contributor opens a PR from a feature/fix branch +2. **Review**: Maintainers review the code and provide feedback +3. **CI Validation**: Automated tests are run +4. **Discussion**: Issues are resolved and necessary changes are made +5. **Approval**: Once approved, the PR is ready for merging +6. **Merge**: The PR is merged into the target branch + +--- + +## Development Environment Setup + +To set up your local environment for contributing: + +1. **Install Android Studio** (version 2022.1 or newer) +2. **Configure Android SDK** (API level 33 recommended) +3. **Install JDK** (version 11 or higher) +4. **Clone the repository**: + + ```bash + git clone https://github.com/darkxvenom/airbeats.git + cd AirBeats + ``` +5. **Build the project using Gradle**: + + ```bash + ./gradlew build + ``` + +--- + +## Translation Contributions + +To help with translations: + +1. Sign up on [Crowdin](https://crowdin.com/project/AirBeats) +2. Choose the language you want to contribute to +3. Translate missing strings or improve existing translations +4. The maintainer team will review and approve submissions + +If your language is not listed, contact us at [cervantesdarkxvenom@gmail.com](mailto:cervantesdarkxvenom@gmail.com). + +--- + +## Design Contributions + +To contribute design ideas: + +1. Review the [Material Design 3 Guidelines](https://m3.material.io/) +2. Create mockups or interactive prototypes +3. Submit your designs in a GitHub issue labeled `design` +4. Include reasoning for how your designs improve the user experience + +--- + +Thank you for contributing to **AirBeats**! Your efforts help us build a better experience for all users. +If you have any questions, feel free to open an issue labeled `question` or reach out directly to the development team. + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.en.md b/README.en.md new file mode 100644 index 00000000..f978fad6 --- /dev/null +++ b/README.en.md @@ -0,0 +1,402 @@ +# AirBeats + +
+ AirBeats Banner + + ### Advanced YouTube Music Client with Material Design 3 for Android + + [![Latest Release](https://img.shields.io/github/v/release/darkxvenom/airbeats?style=flat-square&logo=github&color=0D1117&labelColor=161B22)](https://github.com/darkxvenom/airbeats/releases) + [![License](https://img.shields.io/github/license/darkxvenom/airbeats?style=flat-square&logo=gnu&color=2B3137&labelColor=161B22)](https://github.com/darkxvenom/airbeats/blob/main/LICENSE) + [![Translation Status](https://badges.crowdin.net/AirBeats/localized.svg)](https://crowdin.com/project/AirBeats) + [![Android](https://img.shields.io/badge/Platform-Android%206.0+-3DDC84.svg?style=flat-square&logo=android&logoColor=white&labelColor=161B22)](https://www.android.com) + [![Stars](https://img.shields.io/github/stars/darkxvenom/airbeats?style=flat-square&logo=github&color=yellow&labelColor=161B22)](https://github.com/darkxvenom/airbeats/stargazers) + [![Forks](https://img.shields.io/github/forks/darkxvenom/airbeats?style=flat-square&logo=github&color=blue&labelColor=161B22)](https://github.com/darkxvenom/airbeats/network/members) +
+ +--- + +## Table of Contents + +- [Overview](#overview) +- [Technology Stack](#technology-stack) +- [Key Features](#key-features) +- [Documentation](#documentation) +- [Installation](#installation) +- [Building from Source](#building-from-source) +- [Contributing](#contributing) +- [Support the Project](#support-the-project) +- [Acknowledgments](#acknowledgments) +- [License](#license) + +--- + +## Overview + +**AirBeats** is an open-source YouTube Music client specifically designed for Android devices. It delivers a superior user experience with a modern interface implementing Material Design 3, offering advanced functionalities to explore, play, and manage musical content without the limitations of the official application. + +### Key Benefits + +- **Ad-free Experience**: Enjoy uninterrupted music streaming +- **Enhanced Performance**: Optimized for smooth playback and navigation +- **Privacy-focused**: No data collection or tracking +- **Customizable Interface**: Personalize your music experience +- **Offline Capabilities**: Download and play music without internet connection + +> **Note**: AirBeats is an independent project and is not affiliated, sponsored, or endorsed by YouTube or Google. + +--- + +## Technology Stack + +
+ +| Frontend | Backend | Development Tools | +|:--------:|:-------:|:----------------:| +| ![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white) | ![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white) | ![Android Studio](https://img.shields.io/badge/Android%20Studio-3DDC84?style=for-the-badge&logo=androidstudio&logoColor=white) | +| ![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white) | ![TensorFlow](https://img.shields.io/badge/TensorFlow-FF6F00?style=for-the-badge&logo=tensorflow&logoColor=white) | ![Gradle](https://img.shields.io/badge/Gradle-02303A?style=for-the-badge&logo=gradle&logoColor=white) | +| ![Material Design 3](https://img.shields.io/badge/Material%20Design%203-757575?style=for-the-badge&logo=materialdesign&logoColor=white) | | ![Git](https://img.shields.io/badge/Git-F05032?style=for-the-badge&logo=git&logoColor=white) | + +
+ +--- + +## Key Features + +### Core Functionality + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureDescription
🎵 Ad-free PlaybackEnjoy music without any advertising interruptions
🔄 Background PlaybackContinue listening while using other applications
🔍 Advanced SearchQuickly find songs, videos, albums, and playlists
👤 Account IntegrationSign in to sync preferences and collections
📚 Library ManagementOrganize and fully manage your music collection
📱 Offline ModeDownload content for offline listening
+ +### Audio Enhancement + + + + + + + + + + + + + + + + + + + + + +
FeatureDescription
🎤 Synchronized LyricsView perfectly synchronized song lyrics
⚡ Smart Silence SkipAutomatically skip segments without audio
🔊 Volume NormalizationBalance sound levels between different tracks
🎛️ Tempo & Pitch ControlAdjust playback speed and pitch to preferences
+ +### Personalization & Integration + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureDescription
🎨 Dynamic ThemingInterface adapts to album artwork colors
🌐 Multi-language SupportAvailable in numerous languages for global users
🚗 Android Auto CompatibleIntegration with vehicle infotainment systems
🎯 Material Design 3Design aligned with Google's latest design guidelines
🖼️ Artwork ExportSave high-resolution album images
+ +--- + +## Documentation + +For detailed information about configuration, advanced features, and usage guides, consult our official documentation: + +
+ +[![Documentation](https://img.shields.io/badge/Documentation-GitBook-4285F4?style=for-the-badge&logo=gitbook&logoColor=white)](https://AirBeats.gitbook.io/) + +
+ +--- + +## Installation + +### System Requirements + +| Component | Minimum Requirement | +|:----------|:--------------------| +| Operating System | Android 6.0 (Marshmallow) or higher | +| Storage Space | 10 MB available | +| Network | Internet connection for streaming | +| RAM | 2 GB recommended | + +### Installation Methods + +#### Option 1: GitHub Releases (Recommended) + +1. Navigate to the [Releases](https://github.com/darkxvenom/airbeats/releases) section on GitHub +2. Download the APK file from the latest stable version +3. Enable "Install from unknown sources" in your device's security settings +4. Open the downloaded APK file to complete installation + +#### Option 2: Official Website + +1. Visit the official [AirBeats website](https://AirBeats.netlify.app/) +2. Select the download option for Android +3. Follow the installation instructions provided + +#### Option 3: F-Droid + +
+ +[![F-Droid](https://img.shields.io/badge/F--Droid-1976D2?style=for-the-badge&logo=f-droid&logoColor=white)](https://f-droid.org/es/packages/com.darkxvenom.airbeats/) + +
+ +#### Option 4: OpenApk + +
+ +[![OpenApk](https://img.shields.io/badge/OpenApk-FF6B35?style=for-the-badge&logo=android&logoColor=white)](https://www.openapk.net/AirBeats/com.darkxvenom.airbeats/) + +
+ +> **Security Notice**: For security reasons, it is recommended to obtain the application exclusively through the official channels mentioned above. Avoid downloading APKs from unverified sources. + +--- + +## Building from Source + +### Prerequisites + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolRecommended VersionPurpose
Gradle7.5 or higherBuild automation
Kotlin1.7 or higherProgramming language
Android Studio2022.1 or higherIDE and development environment
JDK11 or higherJava runtime environment
Android SDKAPI level 33 (Android 13)Android development tools
+ +### Firebase Configuration (google-services.json) + +> [!IMPORTANT] +> For security and privacy reasons, the official `google-services.json` configuration file is not included in this repository. To compile and run the application, you must configure your own Firebase project: +> +> 1. Open the [Firebase Console](https://console.firebase.google.com/). +> 2. Create a new Firebase project (or use an existing one) and click **Add app** (Android). +> 3. Register your app using the package name **`com.darkxvenom.airbeats`**. +> 4. Download the generated **`google-services.json`** file. +> 5. Place the **`google-services.json`** file directly in the **`app/`** folder of this project (i.e., `AirBeats/app/google-services.json`). + +### Environment Setup + +```bash +# Clone the repository +git clone https://github.com/darkxvenom/airbeats.git + +# Navigate to project directory +cd AirBeats + +# Update submodules (if any) +git submodule update --init --recursive +``` + +### Build Methods + +#### Android Studio Build + +1. Open Android Studio +2. Select "Open an existing Android Studio project" +3. Navigate and select the AirBeats directory +4. Wait for project synchronization and indexing +5. Select Build → Build Bundle(s) / APK(s) → Build APK(s) + +#### Command Line Build + +```bash +# Build production release +./gradlew assembleRelease + +# Build debug version +./gradlew assembleDebug + +# Full build with tests +./gradlew build + +# Run unit tests +./gradlew test + +# Clean build +./gradlew clean +``` + +> **Note**: Compiled APK files will be located in the `app/build/outputs/apk/` directory. + +--- + +## Contributing + +### Code of Conduct + +All participants in this project must adhere to our code of conduct that promotes an inclusive, respectful, and constructive environment. Please review the [complete Code of Conduct](https://github.com/darkxvenom/airbeats/blob/master/CODE_OF_CONDUCT.md) before contributing. + +### Translation + +Help translate AirBeats into your language or improve existing translations: + +
+ +[![POEditor](https://img.shields.io/badge/POEditor-2196F3?style=for-the-badge&logo=translate&logoColor=white)](https://poeditor.com/join/project/208BwCVazA) +[![Crowdin](https://img.shields.io/badge/Crowdin-2E3440?style=for-the-badge&logo=crowdin&logoColor=white)](https://crowdin.com/project/AirBeats) + +
+ +### Community Channels + +
+ +[![Telegram Chat](https://img.shields.io/badge/Telegram-Chat-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/AirBeats_chat) +[![Telegram Updates](https://img.shields.io/badge/Telegram-Updates-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/AirBeats_updates) + +
+ +### Development Workflow + +1. **Issue Review**: Check [open issues](https://github.com/darkxvenom/airbeats/issues) or create a new one describing the problem or feature +2. **Fork Repository**: Create a personal fork of the repository +3. **Feature Branch**: Create a branch for your feature (`git checkout -b feature/new-feature`) +4. **Implementation**: Implement changes following project coding conventions +5. **Testing**: Ensure code passes all tests (`./gradlew test`) +6. **Commit**: Make commits with descriptive messages (`git commit -m 'feat: add new feature'`) +7. **Push Changes**: Upload changes to your fork (`git push origin feature/new-feature`) +8. **Pull Request**: Open a PR detailing changes and referencing corresponding issue + +> **Development Guidelines**: Review our [contribution guidelines](https://github.com/darkxvenom/airbeats/blob/master/CONTRIBUTING.md) for detailed information about development process, code standards, and workflow. + +--- + +## Support the Project + +If you find value in **AirBeats** and want to contribute to its continued development, consider making a donation. Your financial support allows us to: + +- Implement new features and improvements +- Fix bugs and optimize performance +- Maintain project infrastructure +- Dedicate more time to development and maintenance + +
+ +[![GitHub Sponsors](https://img.shields.io/badge/GitHub_Sponsors-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/darkxvenom) +[![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](mailto:cervantesdarkxvenom@gmail.com) + +
+ +> **Note**: Donations are completely optional. AirBeats will always be free and open-source, regardless of financial support received. + +--- + +## Acknowledgments + +Special thanks to the following contributors and supporters: + +- **mostafaalagamy** - MetroList implementation +- **Fabito02** - Unconditional support from the beginning +- **Community translators** - Making AirBeats accessible worldwide +- **Beta testers** - Helping improve stability and usability + +--- + +## License + +**Copyright © 2025 Arturo Cervantes** + +This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, but **WITHOUT ANY WARRANTY**; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the [GNU General Public License](https://github.com/darkxvenom/airbeats/blob/main/LICENSE) for more details. + +
+ +[![GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg?style=for-the-badge&logo=gnu&logoColor=white)](https://www.gnu.org/licenses/gpl-3.0) + +
+ +> **Important**: Any unauthorized commercial use of this software or its derivatives constitutes a violation of the license terms. + +--- + +
+

© 2023-2024 Open Source Projects

+

Developed with passion by Arturo Cervantes

+ +
+ + [![GitHub](https://img.shields.io/badge/GitHub-100000?style=for-the-badge&logo=github&logoColor=white)](https://github.com/darkxvenom/airbeats) + [![Email](https://img.shields.io/badge/Email-D14836?style=for-the-badge&logo=gmail&logoColor=white)](mailto:cervantesdarkxvenom@gmail.com) + +
diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..2b5f3ed8 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,164 @@ +@file:Suppress("UnstableApiUsage") + +plugins { + id("com.android.application") + kotlin("android") + kotlin("plugin.serialization") version "2.1.0" + kotlin("kapt") + id("com.google.gms.google-services") + alias(libs.plugins.hilt) + alias(libs.plugins.kotlin.ksp) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.darkxvenom.airbeats" + //noinspection GradleDependency + compileSdk = 36 + + defaultConfig { + applicationId = "com.darkxvenom.airbeats" + minSdk = 24 + targetSdk = 35 + versionCode = 128 + versionName = "4.3.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + isCrunchPngs = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + applicationIdSuffix = ".debug" + } + } + + signingConfigs { + getByName("debug") { + if (System.getenv("MUSIC_DEBUG_SIGNING_STORE_PASSWORD") != null) { + storeFile = file(System.getenv("MUSIC_DEBUG_KEYSTORE_FILE")) + storePassword = System.getenv("MUSIC_DEBUG_SIGNING_STORE_PASSWORD") + keyAlias = "debug" + keyPassword = System.getenv("MUSIC_DEBUG_SIGNING_KEY_PASSWORD") + } + } + } + + buildFeatures { + buildConfig = true + compose = true + } + + // ✅ Alineamos TODO a Java 17 + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + kotlin { + jvmToolchain(21) + } + + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + jvmTarget = "21" + } + + testOptions { + unitTests.isIncludeAndroidResources = true + unitTests.isReturnDefaultValues = true + } + + lint { + disable += "MissingTranslation" + } + + dependenciesInfo { + includeInApk = false + includeInBundle = false + } +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + +dependencies { + implementation(libs.guava) + implementation(libs.coroutines.guava) + implementation(libs.concurrent.futures) + + implementation(libs.activity) + implementation(libs.navigation) + implementation(libs.hilt.navigation) + implementation(libs.datastore) + + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.ui.util) + implementation(libs.compose.ui.tooling) + implementation(libs.compose.animation) + implementation(libs.compose.reorderable) + + implementation(libs.viewmodel) + implementation(libs.viewmodel.compose) + + implementation(libs.material3) + implementation(libs.palette) + implementation(projects.materialColorUtilities) + + implementation(libs.coil) + implementation(libs.shimmer) + + implementation(libs.media3) + implementation(libs.media3.session) + implementation(libs.media3.okhttp) + implementation(libs.squigglyslider) + + implementation(libs.room.runtime) + implementation(libs.kotlinx.serialization.json) + implementation(libs.blurry) + implementation(libs.material.ripple) + implementation(libs.room.runtime.android) + implementation(libs.material.icons.extended) + implementation(libs.glance.appwidget) + implementation(libs.glance.material3) + implementation(libs.graphics.shapes) + implementation(libs.work.runtime.ktx) + implementation(libs.constraintlayout) + implementation(libs.itextg) + implementation(libs.mpandroidchart) + implementation(libs.foundation) + implementation(libs.ui.graphics) + implementation(platform("com.google.firebase:firebase-bom:34.11.0")) + implementation("com.google.firebase:firebase-messaging") + ksp(libs.room.compiler) + implementation(libs.room.ktx) + + implementation(libs.apache.lang3) + + implementation(libs.hilt) + implementation("org.jsoup:jsoup:1.18.1") + kapt(libs.hilt.compiler) + + implementation(projects.innertube) + implementation(projects.kugou) + implementation(projects.lrclib) + implementation(projects.kizzy) + implementation(project(":jossredconnect")) + + implementation(libs.ktor.client.core) + + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.timber) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..1b6d4245 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,91 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +## Kotlin Serialization +# Keep `Companion` object fields of serializable classes. +# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects. +-if @kotlinx.serialization.Serializable class ** +-keepclasseswithmembers class <1> { + static <1>$Companion Companion; +} + +# Keep `serializer()` on companion objects (both default and named) of serializable classes. +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclasseswithmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep `INSTANCE.serializer()` of serializable objects. +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclasseswithmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# @Serializable and @Polymorphic are used at runtime for polymorphic serialization. +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +-dontwarn javax.servlet.ServletContainerInitializer +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-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 + +## Rules for NewPipeExtractor +-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } +-keep class org.mozilla.javascript.** { *; } +-keep class org.mozilla.javascript.engine.** { *; } +-dontwarn org.mozilla.javascript.JavaToJSONConverters +-dontwarn org.mozilla.javascript.tools.** +-keep class javax.script.** { *; } +-dontwarn javax.script.** +-keep class jdk.dynalink.** { *; } +-dontwarn jdk.dynalink.** + +## Logging (does not affect Timber) +-assumenosideeffects class android.util.Log { + public static boolean isLoggable(java.lang.String, int); + public static int v(...); + public static int d(...); + ## Leave in release builds + #public static int i(...); + #public static int w(...); + #public static int e(...); +} + +# Generated automatically by the Android Gradle plugin. +-dontwarn java.beans.BeanDescriptor +-dontwarn java.beans.BeanInfo +-dontwarn java.beans.IntrospectionException +-dontwarn java.beans.Introspector +-dontwarn java.beans.PropertyDescriptor +-dontwarn okhttp3.internal.Util diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/1.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/1.json new file mode 100644 index 00000000..2043e6b3 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/1.json @@ -0,0 +1,297 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "38686a738e9e794eca8e1f635cf072b0", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistId` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `liked` INTEGER NOT NULL, `artworkType` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `download_state` INTEGER NOT NULL, `create_date` INTEGER NOT NULL, `modify_date` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artworkType", + "columnName": "artworkType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "create_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modify_date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_song_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_song_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_artist_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_artist_id` ON `${TABLE_NAME}` (`id`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "playlistId" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlist_playlistId", + "unique": true, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `idInPlaylist` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`playlistId`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idInPlaylist", + "columnName": "idInPlaylist", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlist_song_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "playlistId" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "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, '38686a738e9e794eca8e1f635cf072b0')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/10.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/10.json new file mode 100644 index 00000000..9bcd25dd --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/10.json @@ -0,0 +1,814 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "465b6d837bb0b1291e375df6f08219cb", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, '465b6d837bb0b1291e375df6f08219cb')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/11.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/11.json new file mode 100644 index 00000000..c3981e17 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/11.json @@ -0,0 +1,796 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "de2e37d1206f721ad51de3a08f66f99c", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, 'de2e37d1206f721ad51de3a08f66f99c')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/12.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/12.json new file mode 100644 index 00000000..2570d1e7 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/12.json @@ -0,0 +1,812 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "8db3d5731dbcc716a90427d4dde63c66", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, '8db3d5731dbcc716a90427d4dde63c66')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/13.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/13.json new file mode 100644 index 00000000..fd18c487 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/13.json @@ -0,0 +1,812 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "8db3d5731dbcc716a90427d4dde63c66", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, '8db3d5731dbcc716a90427d4dde63c66')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/14.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/14.json new file mode 100644 index 00000000..40db0382 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/14.json @@ -0,0 +1,824 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "8d828b8d2d5ddc5730c653d29c853ff0", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `createdAt` INTEGER, `lastUpdateTime` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, '8d828b8d2d5ddc5730c653d29c853ff0')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/15.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/15.json new file mode 100644 index 00000000..a1ddc19f --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/15.json @@ -0,0 +1,854 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "b2aefbaf97375d551a710d2cbc5e3393", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `localPath` TEXT, `dateDownload` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateModified", + "columnName": "dateModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateDownload", + "columnName": "dateDownload", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, 'b2aefbaf97375d551a710d2cbc5e3393')" + ] + } +} diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/16.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/16.json new file mode 100644 index 00000000..add470d0 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/16.json @@ -0,0 +1,931 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "b78ea238955043c3308d49775d7267a8", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `localPath` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateModified", + "columnName": "dateModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "dateDownload", + "columnName": "dateDownload", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `isEditable` INTEGER NOT NULL DEFAULT true, `isLocal` INTEGER NOT NULL DEFAULT false, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEditable", + "columnName": "isEditable", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `setVideoId` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "set_video_id", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `setVideoId` TEXT, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "videoId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, 'b78ea238955043c3308d49775d7267a8')" + ] + } +} diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/17.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/17.json new file mode 100644 index 00000000..d37bc545 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/17.json @@ -0,0 +1,953 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "59f80ce4b59b0c31db6e5895871ae26d", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateModified", + "columnName": "dateModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateDownload", + "columnName": "dateDownload", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `createdAt` INTEGER, `lastUpdateTime` INTEGER, `isEditable` INTEGER NOT NULL DEFAULT true, `bookmarkedAt` INTEGER, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEditable", + "columnName": "isEditable", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "remoteSongCount", + "columnName": "remoteSongCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "playEndpointParams", + "columnName": "playEndpointParams", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleEndpointParams", + "columnName": "shuffleEndpointParams", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioEndpointParams", + "columnName": "radioEndpointParams", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `setVideoId` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, `playbackUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playbackUrl", + "columnName": "playbackUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "set_video_id", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `setVideoId` TEXT, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "videoId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, '59f80ce4b59b0c31db6e5895871ae26d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/18.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/18.json new file mode 100644 index 00000000..6fa211eb --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/18.json @@ -0,0 +1,993 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "a92d7d81fc8b49d3b1e9f92f6a1c4b7e", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateModified", + "columnName": "dateModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateDownload", + "columnName": "dateDownload", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `createdAt` INTEGER, `lastUpdateTime` INTEGER, `isEditable` INTEGER NOT NULL DEFAULT true, `bookmarkedAt` INTEGER, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEditable", + "columnName": "isEditable", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "remoteSongCount", + "columnName": "remoteSongCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "playEndpointParams", + "columnName": "playEndpointParams", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleEndpointParams", + "columnName": "shuffleEndpointParams", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioEndpointParams", + "columnName": "radioEndpointParams", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `setVideoId` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, `playbackUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playbackUrl", + "columnName": "playbackUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playCount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`song`, `year`, `month`))", + "fields": [ + { + "fieldPath": "song", + "columnName": "song", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "month", + "columnName": "month", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song", + "year", + "month" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "set_video_id", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `setVideoId` TEXT, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "videoId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, 'a92d7d81fc8b49d3b1e9f92f6a1c4b7e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/19.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/19.json new file mode 100644 index 00000000..74712efe --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/19.json @@ -0,0 +1,1000 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "f1de4c2b6f867f85b0aee618efbb2213", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateModified", + "columnName": "dateModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "dateDownload", + "columnName": "dateDownload", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `channelId` TEXT, `songCount` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlistId` TEXT, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "likedDate", + "columnName": "likedDate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, `createdAt` INTEGER, `lastUpdateTime` INTEGER, `isEditable` INTEGER NOT NULL DEFAULT true, `bookmarkedAt` INTEGER, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isEditable", + "columnName": "isEditable", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "remoteSongCount", + "columnName": "remoteSongCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "playEndpointParams", + "columnName": "playEndpointParams", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleEndpointParams", + "columnName": "shuffleEndpointParams", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioEndpointParams", + "columnName": "radioEndpointParams", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, `setVideoId` TEXT, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, `playbackUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "playbackUrl", + "columnName": "playbackUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "set_video_id", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `setVideoId` TEXT, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "setVideoId", + "columnName": "setVideoId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "videoId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playCount", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`song`, `year`, `month`))", + "fields": [ + { + "fieldPath": "song", + "columnName": "song", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "month", + "columnName": "month", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song", + "year", + "month" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, 'f1de4c2b6f867f85b0aee618efbb2213')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/2.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/2.json new file mode 100644 index 00000000..7e8403cc --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/2.json @@ -0,0 +1,656 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "3a7db15c3d60f94f6a7acc75fad88d79", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `download_state` INTEGER NOT NULL, `create_date` INTEGER NOT NULL, `modify_date` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "create_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modify_date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT, `authorId` TEXT, `year` INTEGER, `thumbnailUrl` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "albumId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "albumId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "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": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, '3a7db15c3d60f94f6a7acc75fad88d79')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/3.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/3.json new file mode 100644 index 00000000..a5301733 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/3.json @@ -0,0 +1,718 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "b0a90e3281fad7803ea9fadbc6aac04f", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `download_state` INTEGER NOT NULL, `create_date` INTEGER NOT NULL, `modify_date` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "create_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modify_date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT, `authorId` TEXT, `year` INTEGER, `thumbnailUrl` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "albumId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "albumId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "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": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, 'b0a90e3281fad7803ea9fadbc6aac04f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/4.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/4.json new file mode 100644 index 00000000..dac17cfb --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/4.json @@ -0,0 +1,744 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "fe70b678dc51b8cad5fd1cb4eadbb95d", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `download_state` INTEGER NOT NULL, `create_date` INTEGER NOT NULL, `modify_date` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "create_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modify_date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT, `authorId` TEXT, `year` INTEGER, `thumbnailUrl` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "albumId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "albumId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "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": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, 'fe70b678dc51b8cad5fd1cb4eadbb95d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/5.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/5.json new file mode 100644 index 00000000..48533ee4 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/5.json @@ -0,0 +1,748 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "2ab124580a16b74c86883a1a06edae27", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `download_state` INTEGER NOT NULL, `create_date` INTEGER NOT NULL, `modify_date` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "create_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modify_date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT, `authorId` TEXT, `year` INTEGER, `thumbnailUrl` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "songId", + "albumId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "albumId", + "artistId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "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": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, '2ab124580a16b74c86883a1a06edae27')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/6.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/6.json new file mode 100644 index 00000000..5ec90c00 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/6.json @@ -0,0 +1,712 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "e099eec2e21e2def3fd2dc8b29798a02", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `modifyDate` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modifyDate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, 'e099eec2e21e2def3fd2dc8b29798a02')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/7.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/7.json new file mode 100644 index 00000000..2924f658 --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/7.json @@ -0,0 +1,718 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "8badff35bb8509366509650a5b15634a", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `inLibrary` INTEGER, `createDate` INTEGER NOT NULL, `modifyDate` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modifyDate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, '8badff35bb8509366509650a5b15634a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/8.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/8.json new file mode 100644 index 00000000..04e7f3af --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/8.json @@ -0,0 +1,766 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "8de04c586d6be08319c8fab4240706ff", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "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": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, '8de04c586d6be08319c8fab4240706ff')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/9.json b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/9.json new file mode 100644 index 00000000..4426115f --- /dev/null +++ b/app/schemas/com.darkxvenom.airbeats.db.InternalDatabase/9.json @@ -0,0 +1,840 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "ccad10efd9b5c5ee1dc9b42c6e3715fd", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `downloadState` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_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": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, 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": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_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": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "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_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) 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": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 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, 'ccad10efd9b5c5ee1dc9b42c6e3715fd')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/res/values/app_name.xml b/app/src/debug/res/values/app_name.xml new file mode 100644 index 00000000..89a5f220 --- /dev/null +++ b/app/src/debug/res/values/app_name.xml @@ -0,0 +1,4 @@ + + + AirBeats Debug + \ No newline at end of file diff --git a/app/src/debug/res/xml-v25/shortcuts.xml b/app/src/debug/res/xml-v25/shortcuts.xml new file mode 100644 index 00000000..c3de074b --- /dev/null +++ b/app/src/debug/res/xml-v25/shortcuts.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e0a9f57b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/splash_anim.json b/app/src/main/assets/splash_anim.json new file mode 100644 index 00000000..a0e72fec --- /dev/null +++ b/app/src/main/assets/splash_anim.json @@ -0,0 +1 @@ +{"v":"5.7.5","fr":100,"ip":0,"op":200,"w":375,"h":812,"ddd":0,"assets":[{"id":"0","u":"","p":"data:image/png;base64,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","w":1536,"h":1024,"e":1},{"id":"1","u":"","p":"data:image/png;base64,UklGRsxUAABXRUJQVlA4WAoAAAAwAAAAfwQA/wIASUNDUMgBAAAAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADZBTFBI1CwAAA0kMmmb1r/tfZ2AiEju7BmlBlCk//8pIpL3tv3v5Ca2nvf9fqvUcluyLFtyIniMB4Y0TE4r53W20x+Zc85hcoIJMAxgkzE4yEmyZKm7fu978KsWyPQ6qr72jogJ0P/+97///e9///vf//73v//973//+9///ve///3vf//73//+97///e9///vf//73v//973//+9///ve///3vf//73//+97///e9///vf//73v//973//+9///ve///3vf//73///z6q+vOH09OSiju21m09+7CqbH736g9sXcGyvf+bJp57//Fv3dve/+P5X/+DVizXmpec+96mnX/tcoZ1H+d1P54+/enHG8ZWnP/HSfGKhfbA5uXSyRbz8T07+lzcvxrj5zPXnn3ry+gcf9kHNhi1b+uGRrv7KyZ9eeLE9OvrcLzx9qS+ePigPum1xEeio9NOb1967yMJXrz/+/MeefPaVDwu5TCUqKoAKeOLGgx9eWHH80hPXXjh+8dJbHzAenC7bAWypAEQFFfnkvbcv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IzqKxH4vjgbf+vq3tjfMxZPth9/5/t9++MLHf+dJZ7MbO227zGXSqgKU2b73168u9x8XF1XufvTVv37s1i889y9vP6NRYBkLUi3UvHXpb7792tUPrnKBZeDNb9z6/uZ7Hz/+7LVL4jNi/7def+aNb7/1YZ68YnHh5Xvv/OAnd95448mTa2PXfbevP/vNO89dO52SuBDz7un9t76rW8v3N5tx77F7MWAu2CyFnp4u/oC9uWjjIcP/7//+97///e9///vf///f//3vf//73//+97///e9///vf//73v//973//+9///ve///3vf//73//+97///e9///vf//73v//973//+9///ve///3vf//73//+97//mQJWUDggAiYAANAhAZ0BKoAEAAM+GQyFQiEIaCS2CgwAwlpbvKfHj/gAZykJH4yeuvJvfL6T+Kv7Xf5v5aOU+v7z53e/YD/G/NDwv6+8q7xv9Q/rv4+f1n/vfVT06/dJ7h36e/z3+1/2X+rf1T/nd/f9pfUH/Ev5B/Tf619+328f7H/Vf6b3Of2L+zf4X+uf475AP4t/LvvA/fb7av+D7JP9F/xXsB/wf+U/d98Vv+S/xnwh/r1/mP8d9///0/QP+Kfyf+0/1v9wP3//8f4AegB6hf8A/lPz/+R7+GH6n+YP9//Jb9oOqU9wP1467scj5P94/y/9r8/v9z4X+/jUC9kf6bxEf67t0LIf8L1AvVP6V/sfuN9JH/A9DfrV/pvcA/lX9G/yvqD/rPBh+r/6L2AP5V/O/9X/cvdn/rv/Z/rfOb9L/9//R/AJ/JP6J/uf7r+VfzdetP9wf//7kX6i/8EDkiVea2vp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTo51aPQf3Bdy1/ju+mI+kiK3Qod6ScPqHDhw4cOHDhw4cOHDhw4cOHDhw4cOHDhw4cMjq0eg1Rcl6+qPcBrhMH/LgcIKSt/Omke2hcoNtWIeG4WUx9aoPqHDhw4cOHDhw4cOHDhw4cOHDhw4cOHDhw4ZHVuYCvqWDFRJvE7/HvRYCjGeuXVweUo4BrdJnDhv/kKcu5vNbX06dOnTp06dOnTp06dOnTp06dOnTpvuKbr+Zq5ww5FxwUUk/b07jU/8OCb/2HhZ9GBsrpHNJMogWb6nLubzW19OnTp06dOnTp06dOnTp06dOnTp06b72Vkzq9NGAwh2YK0DIO9LdMWHVBuLapP6FYrQBe2RN5FYMH/sp06dOnTp06dOnTp06dOnTp06dOnTp06dOnR0j/i5KzaVZTNBXG2+W7RLJINIfczwT2Oza9RLzP7IAkr+vxcdQZJvvg5TB9Q4cOHDhw4cOHDhw4cOHDhw4cOHDhw4cOHDJCTCGro2f7UnKgnN0mYDXv6QdGpyN9lOnTp06dOnTp06dOnTp06dOnTp06dOnTp06OaAPcjAUli14jbNofIb5jLSNPPJpnLxmm4crLw+ocOHDhw4cOHDhw4cOHDhw4cOHDhw4cOHDhkgRARfyfIgY0oXas+DnGA61THh9Q4cOHDhw4cOHDhw4cOHDhw4cOHDhw4cOHDI8q0TVUcSrrE4mkHv2iaZFHPyGcrUTMBGsH1Dhw4cOHDhw4cOHDhw4cOHDhw4cOHDhw4cMkL2W1dYZ8PhF2OrMAVnk+pR2IkJfSLNvsGEfLGQCgwIwfUOHDhw4cOHDhw4cOHDhw4cOHDhw4cOHDhwyEd3ccNaZHLNNTGsAsGrmhbmKHOllNm5EVU5dzea2vp06dOnTp06dOnTp06dOnTp06dOjiFUprlor2zMIxSbORf2HosLzJTc65VoSSmqBENtw3Jm7eS3V5sbpAYcVeD6hw4cOHDhw4cOHDhw4cOHDhw4cOHDhw4ZHVwARuCInHWKzQmhuRDoMryKUbFpjzoThOzsETS2lkvV+H1Dhw4cOHDhw4cOHDhw4cOHDhw4cOHDhwyErZ2daRqz9P3ZPzDnmYrh2oITVgL9DrBm7ti8/TEdEt8aGLj7Az2uOSJV5ra+nTp06dOnTp06dOnTp06dOnTp06b5o9QA/d6w8V8cPl09HRm+dF39RpZw0Xn0jZR5MHo16N6iren1Dhw4cOHDhw4cOHDhw4cOHDhw4cOGTKAji95THePyxOfo3XDI6jxKkE+NpgRdk3qv/yCpy7m81tfTp06dOnTp06dOnTp06dOnTp06dOnTp0dlCkuXJKaPNRu651mRdbX06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOnTp06dOm+AAP7/4+8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcP7YM7+Ms+EEvX/kDkJVA9GjXIKUnvQnbppT7eaF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","w":1536,"h":1024,"e":1}],"layers":[{"ddd":0,"ind":12345679,"ty":4,"nm":"Group Layer 8","sr":1,"ks":{"p":{"a":0,"k":[273.75,785.2889344262295,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[38.42213114754098,38.42213114754098,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[220.741,37.184],[225.501,35.896],[228.749,32.36800000000001],[229.981,27.216000000000008],[228.749,22.12],[225.501,18.592],[220.741,17.304],[215.981,18.592],[212.677,22.12],[211.501,27.216000000000008],[212.677,32.36800000000001],[215.981,35.896],[220.741,37.184],[220.741,37.184],[220.741,37.184]],"i":[[0,0],[-1.380999999999972,0.8586999999999989],[-0.7839999999999918,1.493299999999991],[0,1.903999999999996],[0.8220000000000027,1.493299999999991],[1.382000000000062,0.8586999999999989],[1.79200000000003,0],[1.418999999999983,-0.8586999999999989],[0.8220000000000027,-1.493300000000005],[0,-1.904000000000011],[-0.7839999999999918,-1.5307000000000102],[-1.380999999999972,-0.8586999999999989],[-1.754000000000019,0],[0,0],[0,0]],"o":[[1.79200000000003,0],[1.382000000000062,-0.8586999999999989],[0.8220000000000027,-1.5307000000000102],[0,-1.904000000000011],[-0.7839999999999918,-1.493300000000005],[-1.380999999999972,-0.8586999999999989],[-1.754000000000019,0],[-1.380999999999972,0.8586999999999989],[-0.7839999999999918,1.493299999999991],[0,1.903999999999996],[0.8220000000000027,1.493299999999991],[1.418999999999983,0.8586999999999989],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[221.357,43.06400000000001],[214.917,41.608],[210.49300000000005,37.408],[211.221,36.232],[211.221,42.392],[205.173,42.392],[205.173,0],[211.501,0],[211.501,18.36800000000001],[210.49300000000005,16.912000000000006],[214.973,12.88],[221.357,11.424000000000007],[229.085,13.49600000000001],[234.51700000000005,19.152],[236.533,27.216000000000008],[234.51700000000005,35.28],[229.141,40.992],[221.357,43.06400000000001],[221.357,43.06400000000001],[221.357,43.06400000000001]],"i":[[0,0],[1.942000000000007,0.9706999999999937],[1.045999999999935,1.829300000000003],[-0.2426666666666506,0.3919999999999959],[0,-2.053333333333327],[2.015999999999963,0],[0,14.13066666666667],[-2.109333333333325,0],[0,-6.122666666666674],[0.3360000000000127,0.4853333333333296],[-1.865999999999985,0.9707000000000079],[-2.38900000000001,0],[-2.277000000000044,-1.3813000000000102],[-1.30600000000004,-2.389300000000006],[0,-2.986699999999999],[1.343999999999937,-2.389300000000006],[2.27800000000002,-1.4187000000000012],[2.912000000000035,0],[0,0],[0,0]],"o":[[-2.351999999999975,0],[-1.903999999999996,-0.9707000000000079],[0.2426666666666506,-0.3919999999999959],[0,2.053333333333327],[-2.015999999999963,0],[0,-14.13066666666667],[2.109333333333325,0],[0,6.122666666666667],[-0.3360000000000127,-0.4853333333333296],[1.120000000000005,-1.717300000000009],[1.867000000000075,-0.9706999999999937],[2.875,0],[2.314999999999941,1.381299999999996],[1.343999999999937,2.389300000000006],[0,2.986699999999999],[-1.30600000000004,2.389300000000006],[-2.27699999999993,1.381299999999996],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[181.87,43.06400000000001],[176.438,42],[172.854,38.976],[171.566,34.384],[172.63,29.960000000000008],[176.046,26.656000000000006],[181.814,24.752],[192.342,23.016000000000005],[192.342,28],[183.046,29.624],[179.35,31.248],[178.174,34.16],[179.462,37.016000000000005],[182.878,38.08],[187.35799999999995,36.96000000000001],[190.38199999999995,33.992],[191.446,29.792],[191.446,22.008],[189.766,18.36800000000001],[185.398,16.912000000000006],[180.974,18.256],[178.23,21.616],[172.966,18.98400000000001],[175.71,15.064000000000007],[180.134,12.376],[185.566,11.424000000000007],[191.894,12.768],[196.206,16.52],[197.774,22.008],[197.774,42.392],[191.726,42.392],[191.726,36.904],[193.014,37.072],[190.27,40.264],[186.518,42.336],[181.87,43.06400000000001],[181.87,43.06400000000001],[181.87,43.06400000000001]],"i":[[0,0],[1.567999999999984,0.7092999999999989],[0.8589999999999236,1.2693000000000012],[0,1.7547],[-0.7089999999999463,1.306699999999992],[-1.530000000000086,0.8960000000000008],[-2.313999999999965,0.3733000000000004],[-3.509333333333302,0.5786666666666633],[0,-1.661333333333332],[3.098666666666645,-0.541333333333327],[0.7839999999999918,-0.784000000000006],[0,-1.194699999999997],[-0.8579999999999472,-0.7467000000000041],[-1.381000000000085,0],[-1.268999999999892,0.7466999999999899],[-0.7089999999999463,1.2319999999999993],[0,1.530699999999996],[0,2.594666666666669],[1.120000000000005,0.9332999999999885],[1.829999999999927,0],[1.269999999999982,-0.8960000000000008],[0.5979999999999563,-1.381299999999996],[1.754666666666708,0.8773333333333255],[-1.269000000000005,1.11999999999999],[-1.680000000000064,0.6346999999999952],[-1.903999999999996,0],[-1.828999999999951,-0.8960000000000008],[-1.008000000000038,-1.6053],[0,-2.090699999999998],[0,-6.794666666666672],[2.015999999999963,0],[0,1.829333333333338],[-0.4293333333333749,-0.05599999999999739],[1.120000000000005,-0.8959999999999866],[1.418999999999983,-0.4852999999999952],[1.717999999999961,0],[0,0],[0,0]],"o":[[-2.052999999999997,0],[-1.529999999999973,-0.7467000000000041],[-0.8580000000000609,-1.306699999999992],[0,-1.642700000000005],[0.7469999999999573,-1.306700000000006],[1.530999999999949,-0.8960000000000008],[3.509333333333302,-0.5786666666666633],[0,1.661333333333332],[-3.098666666666645,0.541333333333327],[-1.680000000000064,0.2987000000000108],[-0.7839999999999918,0.7467000000000041],[0,1.157300000000006],[0.8959999999999582,0.7092999999999989],[1.717999999999961,0],[1.307000000000016,-0.7467000000000041],[0.7100000000000364,-1.2693000000000012],[0,-2.594666666666669],[0,-1.493299999999991],[-1.081999999999994,-0.9707000000000079],[-1.680000000000064,0],[-1.232000000000085,0.8586999999999989],[-1.754666666666708,-0.8773333333333255],[0.5599999999999454,-1.493300000000005],[1.269999999999982,-1.157300000000006],[1.717999999999961,-0.6347000000000094],[2.389999999999986,0],[1.866999999999962,0.8960000000000008],[1.045999999999935,1.568000000000012],[0,6.794666666666672],[-2.015999999999963,0],[0,-1.829333333333338],[0.4293333333333749,0.05599999999999739],[-0.70900000000006,1.2319999999999993],[-1.081999999999994,0.8960000000000008],[-1.380999999999972,0.4853000000000094],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[159.072,42.392],[159.072,0],[165.4,0],[165.4,42.392],[159.072,42.392],[159.072,42.392],[159.072,42.392]],"i":[[0,0],[0,14.13066666666667],[-2.109333333333325,0],[0,-14.13066666666667],[2.109333333333325,0],[0,0],[0,0]],"o":[[0,-14.13066666666667],[2.109333333333325,0],[0,14.13066666666667],[-2.109333333333325,0],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[138.952,43.06400000000001],[130.888,40.992],[125.456,35.28],[123.496,27.16],[125.456,19.040000000000006],[130.832,13.49600000000001],[138.448,11.424000000000007],[144.552,12.600000000000009],[149.088,15.848],[151.888,20.49600000000001],[152.896,26.096],[152.84,27.608],[152.616,29.064000000000007],[128.48,29.064000000000007],[128.48,24.024],[149.032,24.024],[146.008,26.320000000000007],[145.616,21.448000000000008],[142.816,18.032],[138.448,16.744],[133.968,18.032],[130.944,21.616],[130.104,27.216000000000008],[130.944,32.592],[134.192,36.176],[139.008,37.464],[143.65599999999995,36.232],[146.736,33.040000000000006],[151.888,35.56],[149.088,39.42400000000001],[144.60799999999995,42.11200000000001],[138.952,43.06400000000001],[138.952,43.06400000000001],[138.952,43.06400000000001]],"i":[[0,0],[2.351999999999975,1.381299999999996],[1.307000000000016,2.389300000000006],[0,2.986699999999999],[-1.307000000000016,2.35199999999999],[-2.240000000000009,1.343999999999994],[-2.836999999999989,0],[-1.79200000000003,-0.784000000000006],[-1.231999999999971,-1.381299999999996],[-0.6349999999999909,-1.754700000000014],[0,-1.978700000000003],[0.03699999999992087,-0.5227000000000004],[0.1119999999999663,-0.4480000000000075],[8.04533333333336,0],[0,1.680000000000007],[-6.850666666666712,0],[1.008000000000038,-0.7653333333333308],[0.6349999999999909,1.4187000000000012],[1.269000000000005,0.8213000000000079],[1.680000000000064,0],[1.307000000000016,-0.8586999999999989],[0.70900000000006,-1.567999999999998],[-0.1490000000000009,-2.202700000000007],[-0.7469999999999573,-1.530699999999996],[-1.380999999999972,-0.8586999999999989],[-1.79200000000003,0],[-1.268999999999892,0.8213000000000079],[-0.7469999999999573,1.306699999999992],[-1.717333333333386,-0.8400000000000034],[1.269000000000005,-1.157300000000006],[1.755000000000109,-0.6720000000000113],[2.052999999999997,0],[0,0],[0,0]],"o":[[-3.024000000000001,0],[-2.315000000000055,-1.4187000000000012],[-1.307000000000016,-2.426699999999997],[0,-3.061299999999989],[1.343999999999937,-2.352000000000004],[2.240000000000009,-1.3813000000000102],[2.277000000000044,0],[1.79200000000003,0.7839999999999918],[1.232000000000085,1.344000000000008],[0.6720000000000255,1.7547],[0,0.4852999999999952],[-0.03700000000003456,0.5227000000000004],[-8.04533333333336,0],[0,-1.680000000000007],[6.850666666666712,0],[-1.008000000000038,0.7653333333333308],[0.3729999999999336,-1.829300000000003],[-0.59699999999998,-1.456000000000003],[-1.232000000000085,-0.8586999999999989],[-1.67999999999995,0],[-1.306999999999903,0.8213000000000079],[-0.7089999999999463,1.530699999999996],[-0.1870000000000118,2.053299999999993],[0.7839999999999918,1.53070000000001],[1.418999999999983,0.8586999999999989],[1.828999999999951,0],[1.307000000000016,-0.8212999999999937],[1.717333333333386,0.8400000000000034],[-0.59699999999998,1.4187000000000012],[-1.231999999999971,1.11999999999999],[-1.716999999999985,0.6346999999999952],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[111.001,7.951999999999998],[111.001,0.6720000000000041],[117.329,0.6720000000000041],[117.329,7.951999999999998],[111.001,7.951999999999998],[111.001,7.951999999999998],[111.001,7.951999999999998]],"i":[[0,0],[0,2.426666666666662],[-2.109333333333325,0],[0,-2.426666666666662],[2.109333333333325,0],[0,0],[0,0]],"o":[[0,-2.426666666666662],[2.109333333333325,0],[0,2.426666666666662],[-2.109333333333325,0],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[111.001,42.392],[111.001,12.096],[117.329,12.096],[117.329,42.392],[111.001,42.392],[111.001,42.392],[111.001,42.392]],"i":[[0,0],[0,10.09866666666667],[-2.109333333333325,0],[0,-10.09866666666667],[2.109333333333325,0],[0,0],[0,0]],"o":[[0,-10.09866666666666],[2.109333333333325,0],[0,10.09866666666666],[-2.109333333333325,0],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[101.41,42.72800000000001],[94.01800000000003,40.040000000000006],[91.38599999999997,32.48],[91.38599999999997,17.808000000000007],[86.06600000000003,17.808000000000007],[86.06600000000003,12.096],[86.90599999999995,12.096],[90.21000000000004,10.864],[91.38599999999997,7.504000000000005],[91.38599999999997,5.152000000000001],[97.71400000000006,5.152000000000001],[97.71400000000006,12.096],[104.602,12.096],[104.602,17.808000000000007],[97.71400000000006,17.808000000000007],[97.71400000000006,32.2],[98.21799999999996,34.888000000000005],[99.84199999999998,36.568],[102.754,37.128],[103.76200000000006,37.072],[104.826,36.96000000000001],[104.826,42.392],[103.09,42.616],[101.41,42.72800000000001],[101.41,42.72800000000001],[101.41,42.72800000000001]],"i":[[0,0],[1.754999999999995,1.792000000000002],[0,3.248000000000005],[0,4.890666666666661],[1.773333333333312,0],[0,1.903999999999996],[-0.2799999999999727,0],[-0.7839999999999918,0.8212999999999937],[0,1.4187000000000012],[0,0.7839999999999989],[-2.109333333333325,0],[0,-2.314666666666668],[-2.295999999999935,0],[0,-1.903999999999996],[2.295999999999935,0],[0,-4.797333333333327],[-0.3360000000000127,-0.7467000000000041],[-0.7469999999999573,-0.4106999999999914],[-1.19500000000005,0],[-0.3730000000000473,0.03730000000000189],[-0.3360000000000127,0.03729999999998768],[0,-1.810666666666663],[0.6349999999999909,-0.07469999999999288],[0.4850000000000136,0],[0,0],[0,0]],"o":[[-3.173000000000002,0],[-1.754999999999995,-1.792000000000002],[0,-4.890666666666661],[-1.773333333333312,0],[0,-1.903999999999996],[0.2799999999999727,0],[1.419000000000096,0],[0.7839999999999918,-0.8213000000000079],[0,-0.7839999999999989],[2.109333333333325,0],[0,2.314666666666668],[2.295999999999935,0],[0,1.903999999999996],[-2.295999999999935,0],[0,4.797333333333327],[0,1.045299999999997],[0.3360000000000127,0.7092999999999989],[0.7470000000000709,0.3733000000000004],[0.2989999999999782,0],[0.3729999999999336,-0.03730000000000189],[0,1.810666666666663],[-0.5230000000000246,0.0747000000000071],[-0.6349999999999909,0.0747000000000071],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[78.71499999999997,42.72800000000001],[71.32299999999998,40.040000000000006],[68.69100000000003,32.48],[68.69100000000003,17.808000000000007],[63.37099999999998,17.808000000000007],[63.37099999999998,12.096],[64.21100000000001,12.096],[67.51499999999999,10.864],[68.69100000000003,7.504000000000005],[68.69100000000003,5.152000000000001],[75.019,5.152000000000001],[75.019,12.096],[81.90700000000004,12.096],[81.90700000000004,17.808000000000007],[75.019,17.808000000000007],[75.019,32.2],[75.52300000000002,34.888000000000005],[77.14699999999999,36.568],[80.05900000000003,37.128],[81.06700000000001,37.072],[82.13099999999997,36.96000000000001],[82.13099999999997,42.392],[80.39499999999998,42.616],[78.71499999999997,42.72800000000001],[78.71499999999997,42.72800000000001],[78.71499999999997,42.72800000000001]],"i":[[0,0],[1.754000000000019,1.792000000000002],[0,3.248000000000005],[0,4.890666666666661],[1.773333333333369,0],[0,1.903999999999996],[-0.2800000000000296,0],[-0.7839999999999918,0.8212999999999937],[0,1.4187000000000012],[0,0.7839999999999989],[-2.109333333333325,0],[0,-2.314666666666668],[-2.295999999999992,0],[0,-1.903999999999996],[2.295999999999992,0],[0,-4.797333333333327],[-0.3360000000000127,-0.7467000000000041],[-0.7470000000000141,-0.4106999999999914],[-1.19500000000005,0],[-0.3740000000000236,0.03730000000000189],[-0.3360000000000127,0.03729999999998768],[0,-1.810666666666663],[0.6340000000000146,-0.07469999999999288],[0.4850000000000136,0],[0,0],[0,0]],"o":[[-3.173999999999978,0],[-1.754999999999995,-1.792000000000002],[0,-4.890666666666661],[-1.773333333333369,0],[0,-1.903999999999996],[0.2800000000000296,0],[1.418000000000006,0],[0.7839999999999918,-0.8213000000000079],[0,-0.7839999999999989],[2.109333333333325,0],[0,2.314666666666668],[2.295999999999992,0],[0,1.903999999999996],[-2.295999999999992,0],[0,4.797333333333327],[0,1.045299999999997],[0.3359999999999559,0.7092999999999989],[0.7460000000000377,0.3733000000000004],[0.297999999999945,0],[0.3730000000000473,-0.03730000000000189],[0,1.810666666666663],[-0.5230000000000246,0.0747000000000071],[-0.6349999999999909,0.0747000000000071],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[44.18799999999999,37.184],[48.94799999999998,35.896],[52.19600000000003,32.36800000000001],[53.428,27.216000000000008],[52.19600000000003,22.12],[48.94799999999998,18.592],[44.18799999999999,17.304],[39.428,18.592],[36.12400000000002,22.12],[34.94799999999998,27.216000000000008],[36.12400000000002,32.36800000000001],[39.428,35.896],[44.18799999999999,37.184],[44.18799999999999,37.184],[44.18799999999999,37.184]],"i":[[0,0],[-1.381999999999948,0.8586999999999989],[-0.7840000000000487,1.493299999999991],[0,1.903999999999996],[0.8209999999999695,1.493299999999991],[1.381000000000029,0.8586999999999989],[1.79200000000003,0],[1.418000000000006,-0.8586999999999989],[0.8209999999999695,-1.493300000000005],[0,-1.904000000000011],[-0.7840000000000487,-1.5307000000000102],[-1.382000000000005,-0.8586999999999989],[-1.754999999999995,0],[0,0],[0,0]],"o":[[1.79200000000003,0],[1.381000000000029,-0.8586999999999989],[0.8209999999999695,-1.5307000000000102],[0,-1.904000000000011],[-0.7840000000000487,-1.493300000000005],[-1.381999999999948,-0.8586999999999989],[-1.754999999999995,0],[-1.382000000000005,0.8586999999999989],[-0.7840000000000487,1.493299999999991],[0,1.903999999999996],[0.8209999999999695,1.493299999999991],[1.418000000000006,0.8586999999999989],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[44.18799999999999,43.06400000000001],[36.18000000000001,40.992],[30.46800000000002,35.336],[28.33999999999997,27.216000000000008],[30.46800000000002,19.096],[36.18000000000001,13.49600000000001],[44.18799999999999,11.424000000000007],[52.19600000000003,13.49600000000001],[57.85199999999998,19.096],[59.98000000000002,27.216000000000008],[57.85199999999998,35.392],[52.139999999999986,41.048],[44.18799999999999,43.06400000000001],[44.18799999999999,43.06400000000001],[44.18799999999999,43.06400000000001]],"i":[[0,0],[2.425999999999988,1.381299999999996],[1.418000000000006,2.389300000000006],[0,3.024000000000001],[-1.41900000000004,2.352000000000004],[-2.389999999999986,1.343999999999994],[-2.949999999999989,0],[-2.352000000000032,-1.3813000000000102],[-1.381999999999948,-2.389300000000006],[0,-3.061300000000003],[1.418000000000006,-2.389299999999992],[2.38900000000001,-1.381299999999996],[2.912000000000035,0],[0,0],[0,0]],"o":[[-2.911999999999978,0],[-2.389999999999986,-1.381299999999996],[-1.41900000000004,-2.389299999999992],[0,-3.061300000000003],[1.418000000000006,-2.389300000000006],[2.38900000000001,-1.3813000000000102],[2.98599999999999,0],[2.388999999999953,1.343999999999994],[1.418000000000006,2.352000000000004],[0,3.061299999999989],[-1.418999999999983,2.389300000000006],[-2.389999999999986,1.343999999999994],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[0,42.392],[0,0.6720000000000041],[6.608000000000004,0.6720000000000041],[6.608000000000004,36.512],[24.639999999999986,36.512],[24.639999999999986,42.392],[0,42.392],[0,42.392],[0,42.392]],"i":[[0,0],[0,13.90666666666666],[-2.202666666666687,0],[0,-11.94666666666666],[-6.01066666666668,0],[0,-1.959999999999994],[8.21333333333331,0],[0,0],[0,0]],"o":[[0,-13.90666666666667],[2.202666666666687,0],[0,11.94666666666667],[6.01066666666668,0],[0,1.959999999999994],[-8.21333333333331,0],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[1,1,1],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[98.08047485351562,-21.67217254638672],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[99.99999403953552,99.99999403953552],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[246.681,42.392],[246.681,0],[253.009,0],[253.009,18.032],[252.001,17.248],[255.585,12.936000000000007],[261.297,11.424000000000007],[267.23299999999995,12.88],[271.265,16.912000000000006],[272.721,22.792],[272.721,42.392],[266.449,42.392],[266.449,24.528000000000006],[265.553,20.664],[263.201,18.2],[259.729,17.304],[256.25699999999995,18.2],[253.849,20.664],[253.009,24.528000000000006],[253.009,42.392],[246.681,42.392],[246.681,42.392],[246.681,42.392]],"i":[[0,0],[0,14.13066666666667],[-2.109333333333325,0],[0,-6.010666666666665],[0.3360000000000127,0.2613333333333259],[-1.643000000000029,0.9706999999999937],[-2.166000000000054,0],[-1.717999999999961,-0.9706999999999937],[-0.9710000000000036,-1.717300000000009],[0,-2.202699999999993],[0,-6.533333333333331],[2.090666666666721,0],[0,5.954666666666668],[0.59699999999998,1.045299999999997],[1.007999999999925,0.5600000000000023],[1.305999999999926,0],[1.045000000000073,-0.5973000000000042],[0.59699999999998,-1.082700000000003],[0,-1.493300000000005],[0,-5.954666666666668],[2.109333333333325,0],[0,0],[0,0]],"o":[[0,-14.13066666666667],[2.109333333333325,0],[0,6.010666666666665],[-0.3360000000000127,-0.2613333333333259],[0.7459999999999809,-1.903999999999996],[1.641999999999967,-1.0080000000000098],[2.240000000000009,0],[1.717000000000098,0.9707000000000079],[0.9700000000000273,1.717299999999994],[0,6.533333333333331],[-2.090666666666721,0],[0,-5.954666666666668],[0,-1.5307000000000102],[-0.5599999999999454,-1.082700000000003],[-1.008000000000038,-0.5973000000000042],[-1.270000000000095,0],[-1.007999999999953,0.5600000000000023],[-0.5600000000000023,1.082700000000003],[0,5.954666666666668],[-2.109333333333325,0],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-354.5325317382812,-77.50520324707031],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[237.089,42.72800000000001],[229.697,40.040000000000006],[227.065,32.48],[227.065,17.808000000000007],[221.745,17.808000000000007],[221.745,12.096],[222.585,12.096],[225.889,10.864],[227.065,7.504000000000005],[227.065,5.152000000000001],[233.393,5.152000000000001],[233.393,12.096],[240.281,12.096],[240.281,17.808000000000007],[233.393,17.808000000000007],[233.393,32.2],[233.897,34.888000000000005],[235.521,36.568],[238.433,37.128],[239.441,37.072],[240.505,36.96000000000001],[240.505,42.392],[238.769,42.616],[237.089,42.72800000000001],[237.089,42.72800000000001],[237.089,42.72800000000001]],"i":[[0,0],[1.755000000000052,1.792000000000002],[0,3.248000000000005],[0,4.890666666666661],[1.773333333333369,0],[0,1.903999999999996],[-0.2800000000000296,0],[-0.7839999999999918,0.8212999999999937],[0,1.4187000000000012],[0,0.7839999999999989],[-2.109333333333325,0],[0,-2.314666666666668],[-2.295999999999992,0],[0,-1.903999999999996],[2.295999999999992,0],[0,-4.797333333333327],[-0.3360000000000127,-0.7467000000000041],[-0.7459999999999809,-0.4106999999999914],[-1.194000000000017,0],[-0.3729999999999905,0.03730000000000189],[-0.3360000000000127,0.03729999999998768],[0,-1.810666666666663],[0.6350000000000477,-0.07469999999999288],[0.48599999999999,0],[0,0],[0,0]],"o":[[-3.173000000000002,0],[-1.753999999999962,-1.792000000000002],[0,-4.890666666666661],[-1.773333333333369,0],[0,-1.903999999999996],[0.2800000000000296,0],[1.418999999999983,0],[0.7839999999999918,-0.8213000000000079],[0,-0.7839999999999989],[2.109333333333325,0],[0,2.314666666666668],[2.295999999999992,0],[0,1.903999999999996],[-2.295999999999992,0],[0,4.797333333333327],[0,1.045299999999997],[0.3359999999999559,0.7092999999999989],[0.7470000000000141,0.3733000000000004],[0.2989999999999782,0],[0.3740000000000236,-0.03730000000000189],[0,1.810666666666663],[-0.5220000000000482,0.0747000000000071],[-0.6339999999999577,0.0747000000000071],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-354.5325317382812,-77.50520324707031],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[210.259,7.951999999999998],[210.259,0.6720000000000041],[216.587,0.6720000000000041],[216.587,7.951999999999998],[210.259,7.951999999999998],[210.259,7.951999999999998],[210.259,7.951999999999998]],"i":[[0,0],[0,2.426666666666662],[-2.109333333333325,0],[0,-2.426666666666662],[2.109333333333325,0],[0,0],[0,0]],"o":[[0,-2.426666666666662],[2.109333333333325,0],[0,2.426666666666662],[-2.109333333333325,0],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-354.5325317382812,-77.50520324707031],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[210.259,42.392],[210.259,12.096],[216.587,12.096],[216.587,42.392],[210.259,42.392],[210.259,42.392],[210.259,42.392]],"i":[[0,0],[0,10.09866666666667],[-2.109333333333325,0],[0,-10.09866666666667],[2.109333333333325,0],[0,0],[0,0]],"o":[[0,-10.09866666666666],[2.109333333333325,0],[0,10.09866666666666],[-2.109333333333325,0],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-354.5325317382812,-77.50520324707031],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[169.688,42.392],[159.272,12.096],[165.992,12.096],[173.944,36.232],[171.592,36.232],[179.712,12.096],[185.48,12.096],[193.544,36.232],[191.192,36.232],[199.2,12.096],[205.92,12.096],[195.448,42.392],[189.736,42.392],[181.56,17.696],[183.632,17.696],[175.456,42.392],[169.688,42.392],[169.688,42.392],[169.688,42.392]],"i":[[0,0],[3.47199999999998,10.09866666666667],[-2.240000000000009,0],[-2.650666666666666,-8.045333333333332],[0.7839999999999918,0],[-2.706666666666649,8.045333333333332],[-1.922666666666657,0],[-2.687999999999988,-8.045333333333332],[0.7839999999999918,0],[-2.669333333333327,8.045333333333332],[-2.240000000000009,0],[3.490666666666641,-10.09866666666667],[1.903999999999996,0],[2.725333333333367,8.232],[-0.6906666666666865,0],[2.72533333333331,-8.232],[1.922666666666657,0],[0,0],[0,0]],"o":[[-3.47199999999998,-10.09866666666666],[2.240000000000009,0],[2.650666666666666,8.045333333333332],[-0.7839999999999918,0],[2.706666666666649,-8.045333333333332],[1.922666666666657,0],[2.687999999999988,8.045333333333332],[-0.7839999999999918,0],[2.669333333333327,-8.045333333333332],[2.240000000000009,0],[-3.490666666666641,10.09866666666666],[-1.903999999999996,0],[-2.725333333333367,-8.232],[0.6906666666666865,0],[-2.72533333333331,8.232],[-1.922666666666657,0],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-354.5325317382812,-77.50520324707031],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[1,1,1],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[155.86146545410156,56.001014709472656],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[99.99999403953552,99.99999403953552],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[132.444,43.06400000000001],[124.38,40.992],[118.948,35.28],[116.988,27.16],[118.948,19.040000000000006],[124.324,13.49600000000001],[131.94,11.424000000000007],[138.044,12.600000000000009],[142.58,15.848],[145.38,20.49600000000001],[146.388,26.096],[146.332,27.608],[146.108,29.064000000000007],[121.972,29.064000000000007],[121.972,24.024],[142.524,24.024],[139.5,26.320000000000007],[139.108,21.448000000000008],[136.308,18.032],[131.94,16.744],[127.46,18.032],[124.436,21.616],[123.596,27.216000000000008],[124.436,32.592],[127.684,36.176],[132.5,37.464],[137.148,36.232],[140.228,33.040000000000006],[145.38,35.56],[142.58,39.42400000000001],[138.1,42.11200000000001],[132.444,43.06400000000001],[132.444,43.06400000000001],[132.444,43.06400000000001]],"i":[[0,0],[2.351999999999975,1.381299999999996],[1.305999999999983,2.389300000000006],[0,2.986699999999999],[-1.307000000000016,2.35199999999999],[-2.240000000000009,1.343999999999994],[-2.838000000000022,0],[-1.79200000000003,-0.784000000000006],[-1.232000000000028,-1.381299999999996],[-0.6350000000000477,-1.754700000000014],[0,-1.978700000000003],[0.03699999999997772,-0.5227000000000004],[0.1120000000000232,-0.4480000000000075],[8.045333333333303,0],[0,1.680000000000007],[-6.850666666666655,0],[1.007999999999981,-0.7653333333333308],[0.6340000000000146,1.4187000000000012],[1.269000000000005,0.8213000000000079],[1.67999999999995,0],[1.305999999999983,-0.8586999999999989],[0.7090000000000032,-1.567999999999998],[-0.1499999999999773,-2.202700000000007],[-0.7470000000000141,-1.530699999999996],[-1.382000000000005,-0.8586999999999989],[-1.791999999999973,0],[-1.269999999999982,0.8213000000000079],[-0.7469999999999573,1.306699999999992],[-1.717333333333329,-0.8400000000000034],[1.269000000000005,-1.157300000000006],[1.754000000000019,-0.6720000000000113],[2.052999999999997,0],[0,0],[0,0]],"o":[[-3.024000000000001,0],[-2.314999999999998,-1.4187000000000012],[-1.307000000000016,-2.426699999999997],[0,-3.061299999999989],[1.343999999999994,-2.352000000000004],[2.240000000000009,-1.3813000000000102],[2.276999999999987,0],[1.791999999999973,0.7839999999999918],[1.231999999999971,1.344000000000008],[0.6719999999999686,1.7547],[0,0.4852999999999952],[-0.03800000000001091,0.5227000000000004],[-8.045333333333303,0],[0,-1.680000000000007],[6.850666666666655,0],[-1.007999999999981,0.7653333333333308],[0.3730000000000473,-1.829300000000003],[-0.5979999999999563,-1.456000000000003],[-1.232000000000028,-0.8586999999999989],[-1.680000000000007,0],[-1.307000000000016,0.8213000000000079],[-0.7100000000000364,1.530699999999996],[-0.186999999999955,2.053299999999993],[0.7839999999999918,1.53070000000001],[1.418000000000006,0.8586999999999989],[1.829000000000008,0],[1.305999999999983,-0.8212999999999937],[1.717333333333329,0.8400000000000034],[-0.5980000000000132,1.4187000000000012],[-1.232000000000028,1.11999999999999],[-1.718000000000018,0.6346999999999952],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-211.7300415039062,-77.67320251464844],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[95.32,37.184],[100.024,35.896],[103.328,32.36800000000001],[104.56,27.216000000000008],[103.328,22.12],[100.024,18.592],[95.32,17.304],[90.56,18.592],[87.256,22.12],[86.08000000000001,27.216000000000008],[87.256,32.36800000000001],[90.50399999999999,35.896],[95.32,37.184],[95.32,37.184],[95.32,37.184]],"i":[[0,0],[-1.381,0.8586999999999989],[-0.7839999999999918,1.493299999999991],[0,1.903999999999996],[0.820999999999998,1.493299999999991],[1.419000000000011,0.8586999999999989],[1.754999999999995,0],[1.418999999999983,-0.8586999999999989],[0.7839999999999918,-1.493300000000005],[0,-1.904000000000011],[-0.7839999999999918,-1.5307000000000102],[-1.381,-0.8586999999999989],[-1.792000000000002,0],[0,0],[0,0]],"o":[[1.754999999999995,0],[1.419000000000011,-0.8586999999999989],[0.820999999999998,-1.5307000000000102],[0,-1.904000000000011],[-0.7839999999999918,-1.493300000000005],[-1.381,-0.8586999999999989],[-1.754999999999995,0],[-1.419000000000011,0.8586999999999989],[-0.7839999999999918,1.493299999999991],[0,1.903999999999996],[0.7839999999999918,1.493299999999991],[1.419000000000011,0.8586999999999989],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-211.7300415039062,-77.67320251464844],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[94.70400000000001,43.06400000000001],[86.864,40.992],[81.43199999999999,35.28],[79.47200000000001,27.216000000000008],[81.488,19.152],[86.91999999999999,13.49600000000001],[94.648,11.424000000000007],[101.088,12.88],[105.512,16.912000000000006],[104.56,18.36800000000001],[104.56,0],[110.832,0],[110.832,42.392],[104.84,42.392],[104.84,36.232],[105.568,37.408],[101.088,41.608],[94.70400000000001,43.06400000000001],[94.70400000000001,43.06400000000001],[94.70400000000001,43.06400000000001]],"i":[[0,0],[2.314999999999998,1.381299999999996],[1.344000000000023,2.389300000000006],[0,2.986699999999999],[-1.343999999999994,2.389300000000006],[-2.276999999999987,1.381299999999996],[-2.875,0],[-1.8669999999999902,-0.9706999999999937],[-1.082999999999998,-1.717300000000009],[0.3173333333333233,-0.4853333333333296],[0,6.122666666666674],[-2.090666666666664,0],[0,-14.13066666666667],[1.99733333333333,0],[0,2.053333333333327],[-0.242666666666679,-0.3919999999999959],[1.941000000000003,-0.9707000000000079],[2.314999999999998,0],[0,0],[0,0]],"o":[[-2.912000000000006,0],[-2.277000000000015,-1.4187000000000012],[-1.306999999999988,-2.389300000000006],[0,-2.986699999999999],[1.343999999999994,-2.389300000000006],[2.277000000000015,-1.3813000000000102],[2.426999999999992,0],[1.867000000000019,0.9707000000000079],[-0.3173333333333233,0.4853333333333296],[0,-6.122666666666674],[2.090666666666664,0],[0,14.13066666666667],[-1.99733333333333,0],[0,-2.053333333333327],[0.242666666666679,0.3919999999999959],[-1.045000000000016,1.829300000000003],[-1.941000000000003,0.9706999999999937],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-211.7300415039062,-77.67320251464844],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[57.40100000000001,43.06400000000001],[51.968999999999994,42],[48.38499999999999,38.976],[47.09700000000001,34.384],[48.161,29.960000000000008],[51.577,26.656000000000006],[57.345,24.752],[67.87299999999999,23.016000000000005],[67.87299999999999,28],[58.577,29.624],[54.881,31.248],[53.70500000000001,34.16],[54.992999999999995,37.016000000000005],[58.40899999999999,38.08],[62.88900000000001,36.96000000000001],[65.91300000000001,33.992],[66.977,29.792],[66.977,22.008],[65.297,18.36800000000001],[60.929,16.912000000000006],[56.505,18.256],[53.761,21.616],[48.496999999999986,18.98400000000001],[51.240999999999985,15.064000000000007],[55.66499999999999,12.376],[61.09700000000001,11.424000000000007],[67.42500000000001,12.768],[71.737,16.52],[73.305,22.008],[73.305,42.392],[67.257,42.392],[67.257,36.904],[68.54499999999999,37.072],[65.80099999999999,40.264],[62.04900000000001,42.336],[57.40100000000001,43.06400000000001],[57.40100000000001,43.06400000000001],[57.40100000000001,43.06400000000001]],"i":[[0,0],[1.568000000000012,0.7092999999999989],[0.8590000000000089,1.2693000000000012],[0,1.7547],[-0.7090000000000032,1.306699999999992],[-1.531000000000006,0.8960000000000008],[-2.314999999999998,0.3733000000000004],[-3.509333333333331,0.5786666666666633],[0,-1.661333333333332],[3.098666666666674,-0.541333333333327],[0.7839999999999918,-0.784000000000006],[0,-1.194699999999997],[-0.8590000000000089,-0.7467000000000041],[-1.381,0],[-1.269000000000005,0.7466999999999899],[-0.7090000000000032,1.2319999999999993],[0,1.530699999999996],[0,2.594666666666669],[1.120000000000005,0.9332999999999885],[1.829000000000008,0],[1.269000000000005,-0.8960000000000008],[0.5970000000000084,-1.381299999999996],[1.754666666666679,0.8773333333333255],[-1.268999999999977,1.11999999999999],[-1.680000000000007,0.6346999999999952],[-1.903999999999996,0],[-1.829000000000008,-0.8960000000000008],[-1.0080000000000098,-1.6053],[0,-2.090699999999998],[0,-6.794666666666672],[2.015999999999991,0],[0,1.829333333333338],[-0.429333333333318,-0.05599999999999739],[1.120000000000005,-0.8959999999999866],[1.418999999999983,-0.4852999999999952],[1.716999999999985,0],[0,0],[0,0]],"o":[[-2.053000000000026,0],[-1.531000000000006,-0.7467000000000041],[-0.8589999999999804,-1.306699999999992],[0,-1.642700000000005],[0.7469999999999857,-1.306700000000006],[1.531000000000006,-0.8960000000000008],[3.509333333333331,-0.5786666666666633],[0,1.661333333333332],[-3.098666666666674,0.541333333333327],[-1.680000000000007,0.2987000000000108],[-0.7839999999999918,0.7467000000000041],[0,1.157300000000006],[0.896000000000015,0.7092999999999989],[1.717000000000013,0],[1.306999999999988,-0.7467000000000041],[0.7089999999999748,-1.2693000000000012],[0,-2.594666666666669],[0,-1.493299999999991],[-1.082999999999998,-0.9707000000000079],[-1.680000000000007,0],[-1.2319999999999993,0.8586999999999989],[-1.754666666666679,-0.8773333333333255],[0.5600000000000023,-1.493300000000005],[1.269000000000005,-1.157300000000006],[1.717000000000013,-0.6347000000000094],[2.388999999999982,0],[1.86699999999999,0.8960000000000008],[1.045000000000016,1.568000000000012],[0,6.794666666666672],[-2.015999999999991,0],[0,-1.829333333333338],[0.429333333333318,0.05599999999999739],[-0.7089999999999748,1.2319999999999993],[-1.082999999999998,0.8960000000000008],[-1.381,0.4853000000000094],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-211.7300415039062,-77.67320251464844],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[0,42.392],[0,0.6720000000000041],[6.159999999999997,0.6720000000000041],[21.84,22.400000000000006],[18.75999999999999,22.400000000000006],[34.16,0.6720000000000041],[40.31999999999999,0.6720000000000041],[40.31999999999999,42.392],[33.768,42.392],[33.768,8.456000000000003],[36.232,9.128],[20.49600000000001,30.632000000000005],[19.824000000000012,30.632000000000005],[4.424000000000007,9.128],[6.608000000000004,8.456000000000003],[6.608000000000004,42.392],[0,42.392],[0,42.392],[0,42.392]],"i":[[0,0],[0,13.90666666666666],[-2.053333333333342,0],[-5.226666666666659,-7.242666666666665],[1.026666666666671,0],[-5.133333333333326,7.242666666666672],[-2.053333333333342,0],[0,-13.90666666666667],[2.183999999999997,0],[0,11.312],[-0.8213333333333424,-0.2240000000000038],[5.245333333333321,-7.168000000000006],[0.2239999999999895,0],[5.133333333333326,7.168000000000006],[-0.7280000000000086,0.2240000000000038],[0,-11.312],[2.202666666666659,0],[0,0],[0,0]],"o":[[0,-13.90666666666667],[2.053333333333342,0],[5.226666666666659,7.242666666666672],[-1.026666666666671,0],[5.133333333333326,-7.242666666666665],[2.053333333333342,0],[0,13.90666666666666],[-2.183999999999997,0],[0,-11.312],[0.8213333333333424,0.2240000000000038],[-5.245333333333321,7.168000000000006],[-0.2239999999999895,0],[-5.133333333333326,-7.168000000000006],[0.7280000000000086,-0.2240000000000038],[0,11.312],[-2.202666666666659,0],[0,0],[0,0],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[-211.7300415039062,-77.67320251464844],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[1,1,1],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[2.91259765625,56.001014709472656],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[99.99999403953552,99.99999403953552],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[702.6863719370097,144],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":72,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0,0,0],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":100,"ix":2},"o":{"a":0,"k":0,"ix":2},"m":1},{"ty":"tr","p":{"a":0,"k":[56.54167175292969,-0.000022762338630855083],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[99.99999403953552,99.99999403953552],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":80,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"tr","p":{"a":0,"k":[122.0000003294881,25.00000012138912],"ix":2},"a":{"a":0,"k":[56.54167175292969,-0.00002288818359375],"ix":2},"s":{"a":0,"k":[34.403572049765366,34.403572049765366],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]}],"ip":0,"op":201,"st":0,"bm":0},{"ddd":0,"ind":1,"ty":3,"nm":"lottielab.com","sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"ip":0,"op":201,"st":0},{"ddd":0,"ind":2,"ty":3,"sr":1,"ks":{"p":{"a":0,"k":[187.5,530.98],"ix":2},"o":{"a":0,"k":100,"ix":2}},"ao":0,"ip":0,"op":201,"st":0,"parent":1},{"ddd":0,"ind":3,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[384,96],[1056,96],[1056,-3744],[1104,-3744],[1104,-3264],[1344,-3264],[1344,-2784],[1584,-2784],[1584,-2304],[1824,-2304],[1824,-1824],[2496,-1824],[2496,-2304],[2736,-2304],[2736,-2784],[2976,-2784],[2976,-3264],[3216,-3264],[3216,-3744],[3264,-3744],[3264,96],[3936,96],[3936,-5376],[3264,-5376],[3264,-4416],[2784,-4416],[2784,-3936],[2544,-3936],[2544,-3456],[2304,-3456],[2304,-2976],[2016,-2976],[2016,-3456],[1776,-3456],[1776,-3936],[1536,-3936],[1536,-4416],[1056,-4416],[1056,-5376],[384,-5376],[384,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[47.74,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":135,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":185,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":135,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":136,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":137,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":138,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":139,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":140,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":141,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":142,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":143,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":144,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":145,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":146,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":147,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":148,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":149,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":150,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":151,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":152,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":153,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":154,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":155,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":156,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":157,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":158,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":159,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":160,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":161,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":162,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":163,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":164,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":165,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":166,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":167,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":168,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":169,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":170,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":171,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":172,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":173,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":174,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":175,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":176,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":177,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":178,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":179,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":180,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":181,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":182,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":183,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":184,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":185,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":4,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[864,96],[2496,96],[2496,-384],[2976,-384],[2976,-4896],[2496,-4896],[2496,-5376],[864,-5376],[864,-4896],[384,-4896],[384,-384],[864,-384],[864,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[1344,-576],[1056,-576],[1056,-4704],[2304,-4704],[2304,-576],[1344,-576]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[37.32,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":120,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":170,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":120,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":121,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":122,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":123,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":124,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":125,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":126,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":127,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":128,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":129,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":130,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":131,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":132,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":133,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":134,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":135,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":136,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":137,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":138,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":139,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":140,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":141,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":142,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":143,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":144,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":145,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":146,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":147,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":148,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":149,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":150,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":151,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":152,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":153,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":154,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":155,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":156,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":157,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":158,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":159,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":160,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":161,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":162,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":163,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":164,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":165,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":166,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":167,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":168,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":169,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":170,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":5,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[384,96],[1056,96],[1056,-3744],[1104,-3744],[1104,-3264],[1344,-3264],[1344,-2784],[1584,-2784],[1584,-2304],[1824,-2304],[1824,-1824],[2064,-1824],[2064,-1344],[2304,-1344],[2304,-864],[2784,-864],[2784,96],[3456,96],[3456,-5376],[2784,-5376],[2784,-1536],[2736,-1536],[2736,-2016],[2496,-2016],[2496,-2496],[2256,-2496],[2256,-2976],[2016,-2976],[2016,-3456],[1776,-3456],[1776,-3936],[1536,-3936],[1536,-4416],[1056,-4416],[1056,-5376],[384,-5376],[384,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[25.85,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":105,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":155,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":105,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":106,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":107,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":108,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":109,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":110,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":111,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":112,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":113,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":114,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":115,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":116,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":117,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":118,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":119,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":120,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":121,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":122,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":123,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":124,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":125,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":126,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":127,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":128,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":129,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":130,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":131,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":132,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":133,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":134,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":135,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":136,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":137,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":138,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":139,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":140,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":141,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":142,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":143,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":144,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":145,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":146,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":147,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":148,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":149,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":150,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":151,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":152,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":153,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":154,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":155,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":6,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[384,96],[2496,96],[2496,-576],[1056,-576],[1056,-2304],[2496,-2304],[2496,-2976],[1056,-2976],[1056,-4704],[2496,-4704],[2496,-5376],[384,-5376],[384,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[17.03,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":90,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":140,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":90,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":91,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":92,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":93,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":94,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":95,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":96,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":97,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":98,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":99,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":100,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":101,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":102,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":103,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":104,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":105,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":106,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":107,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":108,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":109,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":110,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":111,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":112,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":113,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":114,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":115,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":116,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":117,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":118,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":119,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":120,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":121,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":122,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":123,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":124,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":125,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":126,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":127,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":128,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":129,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":130,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":131,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":132,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":133,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":134,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":135,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":136,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":137,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":138,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":139,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":140,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":7,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[1344,96],[2016,96],[2016,-384],[2256,-384],[2256,-864],[2496,-864],[2496,-1344],[2736,-1344],[2736,-1824],[2976,-1824],[2976,-2304],[3216,-2304],[3216,-5376],[2544,-5376],[2544,-2496],[2304,-2496],[2304,-2016],[2064,-2016],[2064,-1536],[1824,-1536],[1824,-1056],[1536,-1056],[1536,-1536],[1296,-1536],[1296,-2016],[1056,-2016],[1056,-2496],[816,-2496],[816,-5376],[144,-5376],[144,-2304],[384,-2304],[384,-1824],[624,-1824],[624,-1344],[864,-1344],[864,-864],[1104,-864],[1104,-384],[1344,-384],[1344,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[6.62,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":75,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":125,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":75,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":76,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":77,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":78,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":79,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":80,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":81,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":82,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":83,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":84,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":85,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":86,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":87,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":88,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":89,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":90,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":91,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":92,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":93,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":94,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":95,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":96,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":97,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":98,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":99,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":100,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":101,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":102,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":103,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":104,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":105,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":106,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":107,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":108,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":109,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":110,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":111,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":112,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":113,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":114,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":115,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":116,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":117,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":118,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":119,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":120,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":121,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":122,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":123,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":124,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":125,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":8,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[144,96],[816,96],[816,-864],[1056,-864],[1056,-1344],[1296,-1344],[1296,-1824],[1536,-1824],[1536,-2304],[1824,-2304],[1824,-1824],[2064,-1824],[2064,-1344],[2304,-1344],[2304,-864],[2544,-864],[2544,96],[3216,96],[3216,-1056],[2976,-1056],[2976,-1536],[2736,-1536],[2736,-2016],[2496,-2016],[2496,-2496],[2256,-2496],[2256,-2784],[2496,-2784],[2496,-3264],[2736,-3264],[2736,-3744],[2976,-3744],[2976,-4224],[3216,-4224],[3216,-5376],[2544,-5376],[2544,-4416],[2304,-4416],[2304,-3936],[2064,-3936],[2064,-3456],[1824,-3456],[1824,-2976],[1536,-2976],[1536,-3456],[1296,-3456],[1296,-3936],[1056,-3936],[1056,-4416],[816,-4416],[816,-5376],[144,-5376],[144,-4224],[384,-4224],[384,-3744],[624,-3744],[624,-3264],[864,-3264],[864,-2784],[1104,-2784],[1104,-2496],[864,-2496],[864,-2016],[624,-2016],[624,-1536],[384,-1536],[384,-1056],[144,-1056],[144,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-9.97,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":60,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":110,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":60,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":61,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":62,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":63,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":64,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":65,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":66,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":67,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":68,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":69,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":70,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":71,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":72,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":73,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":74,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":75,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":76,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":77,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":78,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":79,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":80,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":81,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":82,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":83,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":84,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":85,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":86,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":87,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":88,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":89,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":90,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":91,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":92,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":93,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":94,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":95,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":96,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":97,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":98,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":99,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":100,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":101,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":102,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":103,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":104,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":105,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":106,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":107,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":108,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":109,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":110,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":9,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[384,96],[1056,96],[1056,-2304],[1584,-2304],[1584,-1824],[1824,-1824],[1824,-1344],[2064,-1344],[2064,-864],[2304,-864],[2304,-384],[2544,-384],[2544,96],[3216,96],[3216,-576],[2976,-576],[2976,-1056],[2736,-1056],[2736,-1536],[2496,-1536],[2496,-2016],[2256,-2016],[2256,-2496],[2016,-2496],[2016,-2784],[2256,-2784],[2256,-3264],[2496,-3264],[2496,-3744],[2736,-3744],[2736,-4224],[2976,-4224],[2976,-4704],[3216,-4704],[3216,-5376],[2544,-5376],[2544,-4896],[2304,-4896],[2304,-4416],[2064,-4416],[2064,-3936],[1824,-3936],[1824,-3456],[1584,-3456],[1584,-2976],[1056,-2976],[1056,-5376],[384,-5376],[384,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-26.56,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":45,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":95,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":45,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":46,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":47,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":48,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":49,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":50,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":51,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":52,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":53,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":54,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":55,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":56,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":57,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":58,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":59,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":60,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":61,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":62,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":63,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":64,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":65,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":66,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":67,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":68,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":69,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":70,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":71,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":72,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":73,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":74,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":75,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":76,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":77,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":78,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":79,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":80,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":81,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":82,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":83,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":84,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":85,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":86,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":87,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":88,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":89,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":90,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":91,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":92,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":93,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":94,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":95,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":10,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[384,96],[1056,96],[1056,-1824],[1584,-1824],[1584,-1344],[1824,-1344],[1824,-864],[2064,-864],[2064,-384],[2304,-384],[2304,96],[2976,96],[2976,-576],[2736,-576],[2736,-1056],[2496,-1056],[2496,-1536],[2256,-1536],[2256,-1824],[2496,-1824],[2496,-2304],[2976,-2304],[2976,-4896],[2496,-4896],[2496,-5376],[384,-5376],[384,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[1344,-2496],[1056,-2496],[1056,-4704],[2304,-4704],[2304,-2496],[1344,-2496]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-36.44,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":30,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":80,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":30,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":31,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":32,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":33,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":34,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":35,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":36,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":37,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":38,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":39,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":40,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":41,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":42,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":43,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":44,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":45,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":46,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":47,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":48,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":49,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":50,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":51,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":52,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":53,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":54,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":55,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":56,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":57,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":58,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":59,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":60,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":61,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":62,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":63,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":64,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":65,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":66,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":67,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":68,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":69,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":70,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":71,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":72,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":73,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":74,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":75,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":76,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":77,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":78,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":79,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":80,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":11,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[384,96],[1056,96],[1056,-1344],[2304,-1344],[2304,96],[2976,96],[2976,-3936],[2736,-3936],[2736,-4416],[2496,-4416],[2496,-4896],[2256,-4896],[2256,-5376],[1104,-5376],[1104,-4896],[864,-4896],[864,-4416],[624,-4416],[624,-3936],[384,-3936],[384,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[2064,-3744],[2304,-3744],[2304,-2016],[1056,-2016],[1056,-3744],[1296,-3744],[1296,-4224],[1536,-4224],[1536,-4704],[1824,-4704],[1824,-4224],[2064,-4224],[2064,-3744]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-46.85,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":15,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":65,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":15,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":16,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":17,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":18,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":19,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":20,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":21,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":22,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":23,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":24,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":25,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":26,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":27,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":28,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":29,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":30,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":31,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":32,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":33,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":34,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":35,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":36,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":37,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":38,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":39,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":40,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":41,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":42,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":43,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":44,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":45,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":46,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":47,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":48,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":49,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":50,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":51,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":52,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":53,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":54,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":55,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":56,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":57,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":58,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":59,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":60,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":61,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":62,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":63,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":64,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":65,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":12,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":2,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[384,96],[2496,96],[2496,-384],[2976,-384],[2976,-4896],[2496,-4896],[2496,-5376],[384,-5376],[384,96]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[1056,-576],[1056,-4704],[2304,-4704],[2304,-576],[1056,-576]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-57.26,5.82],"ix":2},"s":{"a":0,"k":[0.22,0.22],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.11,0.106,0.146],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","s":{"a":1,"k":[{"t":0,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":50,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":1,"s":[2],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":2,"s":[4],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":3,"s":[6],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":4,"s":[8],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":5,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":6,"s":[12],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":7,"s":[14],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":8,"s":[16],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":9,"s":[18],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":10,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":11,"s":[22],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":12,"s":[24],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":13,"s":[26],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":14,"s":[28],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":15,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":16,"s":[32],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":17,"s":[34],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":18,"s":[36],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":19,"s":[38],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":20,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":21,"s":[42],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":22,"s":[44],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":23,"s":[46],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":24,"s":[48],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":25,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":26,"s":[52],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":27,"s":[54],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":28,"s":[56],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":29,"s":[58],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":30,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":31,"s":[62],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":32,"s":[64],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":33,"s":[66],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":34,"s":[68],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":35,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":36,"s":[72],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":37,"s":[74],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":38,"s":[76],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":39,"s":[78],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":40,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":41,"s":[82],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":42,"s":[84],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":43,"s":[86],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":44,"s":[88],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":45,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":46,"s":[92],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":47,"s":[94],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":48,"s":[96],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":49,"s":[98],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":50,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"refId":"0","ind":13,"ty":2,"sr":1,"ks":{"p":{"a":0,"k":[187.5,395.94],"ix":2},"a":{"a":0,"k":[768,512],"ix":2},"s":{"a":1,"k":[{"t":0,"s":[0,0],"h":1},{"t":1,"s":[0,0],"h":1},{"t":2,"s":[0.01,0.01],"h":1},{"t":3,"s":[0.01,0.01],"h":1},{"t":4,"s":[0.02,0.02],"h":1},{"t":5,"s":[0.04,0.04],"h":1},{"t":6,"s":[0.05,0.05],"h":1},{"t":7,"s":[0.07,0.07],"h":1},{"t":8,"s":[0.09,0.09],"h":1},{"t":9,"s":[0.12,0.12],"h":1},{"t":10,"s":[0.14,0.14],"h":1},{"t":11,"s":[0.17,0.17],"h":1},{"t":12,"s":[0.21,0.21],"h":1},{"t":13,"s":[0.24,0.24],"h":1},{"t":14,"s":[0.28,0.28],"h":1},{"t":15,"s":[0.32,0.32],"h":1},{"t":16,"s":[0.36,0.36],"h":1},{"t":17,"s":[0.41,0.41],"h":1},{"t":18,"s":[0.46,0.46],"h":1},{"t":19,"s":[0.51,0.51],"h":1},{"t":20,"s":[0.56,0.56],"h":1},{"t":21,"s":[0.62,0.62],"h":1},{"t":22,"s":[0.67,0.67],"h":1},{"t":23,"s":[0.73,0.73],"h":1},{"t":24,"s":[0.8,0.8],"h":1},{"t":25,"s":[0.86,0.86],"h":1},{"t":26,"s":[0.93,0.93],"h":1},{"t":27,"s":[1,1],"h":1},{"t":28,"s":[1.07,1.07],"h":1},{"t":29,"s":[1.15,1.15],"h":1},{"t":30,"s":[1.22,1.22],"h":1},{"t":31,"s":[1.3,1.3],"h":1},{"t":32,"s":[1.38,1.38],"h":1},{"t":33,"s":[1.47,1.47],"h":1},{"t":34,"s":[1.55,1.55],"h":1},{"t":35,"s":[1.64,1.64],"h":1},{"t":36,"s":[1.73,1.73],"h":1},{"t":37,"s":[1.82,1.82],"h":1},{"t":38,"s":[1.92,1.92],"h":1},{"t":39,"s":[2.02,2.01],"h":1},{"t":40,"s":[2.11,2.11],"h":1},{"t":41,"s":[2.21,2.21],"h":1},{"t":42,"s":[2.32,2.32],"h":1},{"t":43,"s":[2.42,2.42],"h":1},{"t":44,"s":[2.53,2.53],"h":1},{"t":45,"s":[2.64,2.64],"h":1},{"t":46,"s":[2.75,2.75],"h":1},{"t":47,"s":[2.86,2.86],"h":1},{"t":48,"s":[2.97,2.97],"h":1},{"t":49,"s":[3.09,3.09],"h":1},{"t":50,"s":[3.21,3.21],"h":1},{"t":51,"s":[3.33,3.33],"h":1},{"t":52,"s":[3.45,3.45],"h":1},{"t":53,"s":[3.57,3.57],"h":1},{"t":54,"s":[3.7,3.7],"h":1},{"t":55,"s":[3.82,3.82],"h":1},{"t":56,"s":[3.95,3.95],"h":1},{"t":57,"s":[4.08,4.08],"h":1},{"t":58,"s":[4.22,4.22],"h":1},{"t":59,"s":[4.35,4.35],"h":1},{"t":60,"s":[4.49,4.48],"h":1},{"t":61,"s":[4.62,4.62],"h":1},{"t":62,"s":[4.76,4.76],"h":1},{"t":63,"s":[4.9,4.9],"h":1},{"t":64,"s":[5.04,5.04],"h":1},{"t":65,"s":[5.19,5.19],"h":1},{"t":66,"s":[5.33,5.33],"h":1},{"t":67,"s":[5.48,5.48],"h":1},{"t":68,"s":[5.63,5.62],"h":1},{"t":69,"s":[5.78,5.77],"h":1},{"t":70,"s":[5.93,5.93],"h":1},{"t":71,"s":[6.08,6.08],"h":1},{"t":72,"s":[6.23,6.23],"h":1},{"t":73,"s":[6.39,6.39],"h":1},{"t":74,"s":[6.54,6.54],"h":1},{"t":75,"s":[6.7,6.7],"h":1},{"t":76,"s":[6.86,6.86],"h":1},{"t":77,"s":[7.02,7.02],"h":1},{"t":78,"s":[7.18,7.18],"h":1},{"t":79,"s":[7.35,7.34],"h":1},{"t":80,"s":[7.51,7.51],"h":1},{"t":81,"s":[7.68,7.67],"h":1},{"t":82,"s":[7.84,7.84],"h":1},{"t":83,"s":[8.01,8.01],"h":1},{"t":84,"s":[8.18,8.18],"h":1},{"t":85,"s":[8.35,8.35],"h":1},{"t":86,"s":[8.52,8.52],"h":1},{"t":87,"s":[8.69,8.69],"h":1},{"t":88,"s":[8.86,8.86],"h":1},{"t":89,"s":[9.04,9.04],"h":1},{"t":90,"s":[9.21,9.21],"h":1},{"t":91,"s":[9.39,9.39],"h":1},{"t":92,"s":[9.57,9.57],"h":1},{"t":93,"s":[9.75,9.74],"h":1},{"t":94,"s":[9.93,9.92],"h":1},{"t":95,"s":[10.11,10.1],"h":1},{"t":96,"s":[10.29,10.28],"h":1},{"t":97,"s":[10.47,10.47],"h":1},{"t":98,"s":[10.65,10.65],"h":1},{"t":99,"s":[10.83,10.83],"h":1},{"t":100,"s":[11.02,11.02],"h":1},{"t":101,"s":[11.2,11.2],"h":1},{"t":102,"s":[11.39,11.39],"h":1},{"t":103,"s":[11.57,11.57],"h":1},{"t":104,"s":[11.76,11.76],"h":1},{"t":105,"s":[11.95,11.95],"h":1},{"t":106,"s":[12.14,12.13],"h":1},{"t":107,"s":[12.32,12.32],"h":1},{"t":108,"s":[12.51,12.51],"h":1},{"t":109,"s":[12.7,12.7],"h":1},{"t":110,"s":[12.89,12.89],"h":1},{"t":111,"s":[13.08,13.08],"h":1},{"t":112,"s":[13.28,13.27],"h":1},{"t":113,"s":[13.47,13.46],"h":1},{"t":114,"s":[13.66,13.66],"h":1},{"t":115,"s":[13.85,13.85],"h":1},{"t":116,"s":[14.04,14.04],"h":1},{"t":117,"s":[14.24,14.23],"h":1},{"t":118,"s":[14.43,14.43],"h":1},{"t":119,"s":[14.62,14.62],"h":1},{"t":120,"s":[14.81,14.81],"h":1},{"t":121,"s":[15.01,15],"h":1},{"t":122,"s":[15.2,15.2],"h":1},{"t":123,"s":[15.39,15.39],"h":1},{"t":124,"s":[15.59,15.58],"h":1},{"t":125,"s":[15.78,15.78],"h":1},{"t":126,"s":[15.97,15.97],"h":1},{"t":127,"s":[16.17,16.16],"h":1},{"t":128,"s":[16.36,16.36],"h":1},{"t":129,"s":[16.55,16.55],"h":1},{"t":130,"s":[16.74,16.74],"h":1},{"t":131,"s":[16.94,16.93],"h":1},{"t":132,"s":[17.13,17.12],"h":1},{"t":133,"s":[17.32,17.32],"h":1},{"t":134,"s":[17.51,17.51],"h":1},{"t":135,"s":[17.7,17.7],"h":1},{"t":136,"s":[17.89,17.89],"h":1},{"t":137,"s":[18.08,18.07],"h":1},{"t":138,"s":[18.27,18.26],"h":1},{"t":139,"s":[18.45,18.45],"h":1},{"t":140,"s":[18.64,18.64],"h":1},{"t":141,"s":[18.83,18.82],"h":1},{"t":142,"s":[19.01,19.01],"h":1},{"t":143,"s":[19.19,19.19],"h":1},{"t":144,"s":[19.38,19.37],"h":1},{"t":145,"s":[19.56,19.55],"h":1},{"t":146,"s":[19.74,19.74],"h":1},{"t":147,"s":[19.92,19.91],"h":1},{"t":148,"s":[20.09,20.09],"h":1},{"t":149,"s":[20.27,20.27],"h":1},{"t":150,"s":[20.45,20.44],"h":1},{"t":151,"s":[20.62,20.61],"h":1},{"t":152,"s":[20.79,20.79],"h":1},{"t":153,"s":[20.96,20.95],"h":1},{"t":154,"s":[21.13,21.12],"h":1},{"t":155,"s":[21.29,21.29],"h":1},{"t":156,"s":[21.45,21.45],"h":1},{"t":157,"s":[21.61,21.61],"h":1},{"t":158,"s":[21.77,21.77],"h":1},{"t":159,"s":[21.93,21.92],"h":1},{"t":160,"s":[22.08,22.08],"h":1},{"t":161,"s":[22.23,22.23],"h":1},{"t":162,"s":[22.38,22.37],"h":1},{"t":163,"s":[22.52,22.52],"h":1},{"t":164,"s":[22.66,22.66],"h":1},{"t":165,"s":[22.8,22.8],"h":1},{"t":166,"s":[22.93,22.93],"h":1},{"t":167,"s":[23.06,23.06],"h":1},{"t":168,"s":[23.19,23.19],"h":1},{"t":169,"s":[23.31,23.31],"h":1},{"t":170,"s":[23.43,23.42],"h":1},{"t":171,"s":[23.54,23.54],"h":1},{"t":172,"s":[23.65,23.64],"h":1},{"t":173,"s":[23.75,23.74],"h":1},{"t":174,"s":[23.84,23.84],"h":1},{"t":175,"s":[23.93,23.93],"h":1},{"t":176,"s":[24.02,24.01],"h":1},{"t":177,"s":[24.09,24.09],"h":1},{"t":178,"s":[24.16,24.16],"h":1},{"t":179,"s":[24.23,24.22],"h":1},{"t":180,"s":[24.28,24.28],"h":1},{"t":181,"s":[24.33,24.32],"h":1},{"t":182,"s":[24.36,24.36],"h":1},{"t":183,"s":[24.39,24.39],"h":1},{"t":184,"s":[24.41,24.4],"h":1},{"t":185,"s":[24.41,24.41],"h":1}],"ix":2},"r":{"a":1,"k":[{"t":0,"s":[-360],"o":{"x":[0.4],"y":[0]},"i":{"x":[0.8],"y":[1]}},{"t":185,"s":[0]}],"ix":2},"o":{"a":0,"k":100,"ix":2}},"ao":0,"ip":0,"op":201,"st":0},{"ddd":0,"ind":14,"ty":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"ip":0,"op":201,"st":0},{"ddd":0,"ind":15,"ty":3,"sr":1,"ks":{"p":{"a":0,"k":[187.5,495.51],"ix":2},"o":{"a":0,"k":100,"ix":2}},"ao":0,"ip":0,"op":201,"st":0,"parent":14},{"ddd":0,"ind":16,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":15,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[1400,-1130],[1070,-1094],[1021.5,-1188],[929,-1259],[787,-1286],[597.5,-1237],[522,-1110],[571.5,-1001],[739,-932],[1001,-876],[1325.5,-727],[1433,-460],[1348.5,-204.5],[1117,-32],[777,30],[323,-88.5],[118,-419],[471,-453],[573,-296],[776,-243],[983.5,-296],[1062,-427],[1011.5,-536],[855,-602],[593,-657],[266,-812.5],[161,-1090],[238.5,-1336.5],[457.5,-1498.5],[782,-1556],[1207.5,-1441],[1400,-1130]],"i":[[0,0],[0,0],[23,29.333],[38.667,18],[56,0],[51,-32.667],[-0.667,-52],[-33.667,-28],[-78,-18],[0,0],[-71,-68],[-0.667,-110],[55.667,-73.667],[98.667,-41.333],[128,0],[114.667,79],[22,141.333],[0,0],[-52,-35.333],[-83.333,0],[-52.333,35.333],[0,52],[33.667,28.667],[70.667,15.333],[0,0],[70.667,73],[-0.667,112],[-52.333,69.667],[-93.667,38.333],[-122.667,0],[-103.667,-76.667],[-24.667,-130.667]],"o":[[0,0],[-9.333,-33.333],[-23,-29.333],[-38.667,-18],[-75.333,0],[-51,32.667],[-0.667,44.667],[33.667,28],[0,0],[145.333,31.333],[71,68],[-0.667,96.667],[-55.667,73.667],[-98.667,41.333],[-188,0],[-114.667,-79],[0,0],[16,69.333],[52,35.333],[86,0],[52.333,-35.333],[0,-44],[-33.667,-28.667],[0,0],[-147.333,-30.667],[-70.667,-73],[-0.667,-94.667],[52.333,-69.667],[93.667,-38.333],[180,0],[103.667,76.667],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[63.67,15.27],"ix":2},"s":{"a":0,"k":[1.49,1.49],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":4,"k":{"a":0,"k":[0.0866,0,0.565,0.973,0.3961,0.695,0.385,0.993,0.6299,0.95,0.255,0.38,0.8862,0.96,0.326,0.036,0.0866,0.99999,0.397,0.99999,0.6299,0.999995,0.88621,1],"ix":2}},"s":{"a":0,"k":[-117.95,0],"ix":2},"e":{"a":0,"k":[117.95,0],"ix":2},"t":1},{"ty":"tr","s":{"a":1,"k":[{"t":125,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":185,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":125,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":126,"s":[1.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":127,"s":[3.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":128,"s":[5],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":129,"s":[6.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":130,"s":[8.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":131,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":132,"s":[11.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":133,"s":[13.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":134,"s":[15],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":135,"s":[16.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":136,"s":[18.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":137,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":138,"s":[21.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":139,"s":[23.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":140,"s":[25],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":141,"s":[26.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":142,"s":[28.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":143,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":144,"s":[31.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":145,"s":[33.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":146,"s":[35],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":147,"s":[36.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":148,"s":[38.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":149,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":150,"s":[41.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":151,"s":[43.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":152,"s":[45],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":153,"s":[46.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":154,"s":[48.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":155,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":156,"s":[51.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":157,"s":[53.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":158,"s":[55],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":159,"s":[56.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":160,"s":[58.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":161,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":162,"s":[61.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":163,"s":[63.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":164,"s":[65],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":165,"s":[66.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":166,"s":[68.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":167,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":168,"s":[71.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":169,"s":[73.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":170,"s":[75],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":171,"s":[76.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":172,"s":[78.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":173,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":174,"s":[81.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":175,"s":[83.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":176,"s":[85],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":177,"s":[86.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":178,"s":[88.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":179,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":180,"s":[91.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":181,"s":[93.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":182,"s":[95],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":183,"s":[96.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":184,"s":[98.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":185,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":17,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":15,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[953,-1536],[953,-1256],[70,-1256],[70,-1536],[953,-1536]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[288,-1904],[650,-1904],[650,-462],[672.5,-350.5],[732,-298],[814,-284],[876.5,-289],[920,-298],[981,-15],[898.5,7],[768,21],[523,-20.5],[350.5,-162],[288,-402],[288,-1904]],"i":[[0,0],[0,0],[0,0],[-15,-25.667],[-24.667,-9.333],[-30,0],[-19,3.333],[-10,2.667],[0,0],[35.667,-8],[51.333,-1.333],[72.667,30.333],[42.333,64],[-0.667,96],[0,0]],"o":[[0,0],[0,0],[0,48.667],[15,25.667],[24.667,9.333],[22.667,0],[19,-3.333],[0,0],[-19.333,6.667],[-35.667,8],[-90.667,2.667],[-72.667,-30.333],[-42.333,-64],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[48.06,15.27],"ix":2},"s":{"a":0,"k":[1.49,1.49],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":4,"k":{"a":0,"k":[0.0866,0,0.565,0.973,0.3961,0.695,0.385,0.993,0.6299,0.95,0.255,0.38,0.8862,0.96,0.326,0.036,0.0866,0.99999,0.397,0.99999,0.6299,0.999995,0.88621,1],"ix":2}},"s":{"a":0,"k":[-117.95,0],"ix":2},"e":{"a":0,"k":[117.95,0],"ix":2},"t":1},{"ty":"tr","s":{"a":1,"k":[{"t":107,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":167,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":107,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":108,"s":[1.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":109,"s":[3.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":110,"s":[5],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":111,"s":[6.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":112,"s":[8.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":113,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":114,"s":[11.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":115,"s":[13.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":116,"s":[15],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":117,"s":[16.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":118,"s":[18.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":119,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":120,"s":[21.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":121,"s":[23.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":122,"s":[25],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":123,"s":[26.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":124,"s":[28.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":125,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":126,"s":[31.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":127,"s":[33.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":128,"s":[35],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":129,"s":[36.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":130,"s":[38.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":131,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":132,"s":[41.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":133,"s":[43.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":134,"s":[45],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":135,"s":[46.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":136,"s":[48.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":137,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":138,"s":[51.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":139,"s":[53.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":140,"s":[55],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":141,"s":[56.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":142,"s":[58.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":143,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":144,"s":[61.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":145,"s":[63.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":146,"s":[65],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":147,"s":[66.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":148,"s":[68.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":149,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":150,"s":[71.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":151,"s":[73.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":152,"s":[75],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":153,"s":[76.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":154,"s":[78.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":155,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":156,"s":[81.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":157,"s":[83.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":158,"s":[85],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":159,"s":[86.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":160,"s":[88.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":161,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":162,"s":[91.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":163,"s":[93.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":164,"s":[95],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":165,"s":[96.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":166,"s":[98.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":167,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":18,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":15,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[628,31],[365.5,-21.5],[181.5,-177],[114,-431],[162,-646],[293,-782],[480.5,-859.5],[697,-898],[916,-923.5],[1038.5,-959.5],[1077,-1036],[1077,-1042],[1010,-1217],[817,-1279],[606.5,-1221],[502,-1084],[164,-1132],[296,-1366.5],[521,-1508.5],[815,-1556],[1036,-1530],[1237,-1444.5],[1383.5,-1283],[1439,-1028],[1439,0],[1091,0],[1091,-211],[1079,-211],[986.5,-91.5],[837.5,-2.5],[628,31]],"i":[[0,0],[77.667,35],[45,68.667],[0,100.667],[-32,56.667],[-55.333,34],[-69.667,17.667],[-74.667,8],[-56,7.667],[-25.667,16.333],[0,34.667],[0,0],[44.667,41.333],[84,0],[51.667,-38.667],[18,-52.667],[0,0],[-61.333,63],[-88.667,31.667],[-107.333,0],[-73.333,-17.333],[-60.667,-39.667],[-37,-68],[0,-102],[0,0],[0,0],[0,0],[0,0],[39.667,-37],[59.667,-22.333],[80,0]],"o":[[-97.333,0],[-77.667,-35],[-45,-68.667],[0,-86.667],[32,-56.667],[55.333,-34],[69.667,-17.667],[90,-9.333],[56,-7.667],[25.667,-16.333],[0,0],[0,-75.333],[-44.667,-41.333],[-88.667,0],[-51.667,38.667],[0,0],[26.667,-93.333],[61.333,-63],[88.667,-31.667],[74,0],[73.333,17.333],[60.667,39.667],[37,68],[0,0],[0,0],[0,0],[0,0],[-22,42.667],[-39.667,37],[-59.667,22.333],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[722,-235],[911,-278.5],[1034.5,-394],[1078,-551],[1078,-732],[1020.5,-706],[930,-685],[831,-669],[746,-657],[601,-621],[500,-551.5],[463,-437],[536,-286],[722,-235]],"i":[[0,0],[-53.333,29],[-29,48],[0,56.667],[0,0],[27,-8],[33.333,-6],[32.667,-4.667],[24,-3.333],[42.667,-16.667],[24.667,-29.667],[0,-46.667],[-48.667,-34],[-75.333,0]],"o":[[72.667,0],[53.333,-29],[29,-48],[0,0],[-11.333,9.333],[-27,8],[-33.333,6],[-32.667,4.667],[-54,7.333],[-42.667,16.667],[-24.667,29.667],[0,66.667],[48.667,34],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[23.92,15.27],"ix":2},"s":{"a":0,"k":[1.49,1.49],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":4,"k":{"a":0,"k":[0.0866,0,0.565,0.973,0.3961,0.695,0.385,0.993,0.6299,0.95,0.255,0.38,0.8862,0.96,0.326,0.036,0.0866,0.99999,0.397,0.99999,0.6299,0.999995,0.88621,1],"ix":2}},"s":{"a":0,"k":[-117.95,0],"ix":2},"e":{"a":0,"k":[117.95,0],"ix":2},"t":1},{"ty":"tr","s":{"a":1,"k":[{"t":90,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":150,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":90,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":91,"s":[1.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":92,"s":[3.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":93,"s":[5],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":94,"s":[6.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":95,"s":[8.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":96,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":97,"s":[11.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":98,"s":[13.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":99,"s":[15],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":100,"s":[16.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":101,"s":[18.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":102,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":103,"s":[21.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":104,"s":[23.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":105,"s":[25],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":106,"s":[26.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":107,"s":[28.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":108,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":109,"s":[31.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":110,"s":[33.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":111,"s":[35],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":112,"s":[36.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":113,"s":[38.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":114,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":115,"s":[41.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":116,"s":[43.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":117,"s":[45],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":118,"s":[46.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":119,"s":[48.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":120,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":121,"s":[51.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":122,"s":[53.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":123,"s":[55],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":124,"s":[56.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":125,"s":[58.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":126,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":127,"s":[61.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":128,"s":[63.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":129,"s":[65],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":130,"s":[66.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":131,"s":[68.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":132,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":133,"s":[71.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":134,"s":[73.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":135,"s":[75],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":136,"s":[76.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":137,"s":[78.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":138,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":139,"s":[81.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":140,"s":[83.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":141,"s":[85],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":142,"s":[86.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":143,"s":[88.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":144,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":145,"s":[91.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":146,"s":[93.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":147,"s":[95],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":148,"s":[96.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":149,"s":[98.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":150,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":19,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":15,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[866,30],[467.5,-66.5],[210,-340.5],[120,-759],[210.5,-1175.5],[464,-1455.5],[847,-1556],[1115.5,-1510.5],[1339.5,-1370],[1493,-1128.5],[1549,-780],[1549,-669],[290,-669],[290,-913],[1202,-913],[1157,-1098.5],[1034.5,-1227],[852,-1274],[657,-1220.5],[526.5,-1080.5],[479,-891],[479,-678],[528,-448.5],[665,-302.5],[871,-252],[1014,-274.5],[1125,-341],[1196,-450],[1534,-412],[1412.5,-178.5],[1184,-24.5],[866,30]],"i":[[0,0],[111.667,64.333],[60,118.333],[0,160.667],[-60.333,119.667],[-108.667,67],[-146.667,0],[-84.333,-30.333],[-65,-63.333],[-37.333,-97.667],[0,-134.667],[0,0],[0,0],[0,0],[0,0],[29.333,54.333],[52.333,31.333],[69.333,0],[56,-35.667],[31,-57.667],[0.667,-68.667],[0,0],[-32.667,-63.667],[-58.667,-33.667],[-78.667,0],[-42.667,15],[-31.333,29.333],[-16,43.333],[0,0],[59.667,-66.333],[92.667,-36.333],[119.333,0]],"o":[[-154,0],[-111.667,-64.333],[-60,-118.333],[0,-158],[60.333,-119.667],[108.667,-67],[94.667,0],[84.333,30.333],[65,63.333],[37.333,97.667],[0,0],[0,0],[0,0],[0,0],[-0.667,-69.333],[-29.333,-54.333],[-52.333,-31.333],[-74,0],[-56,35.667],[-31,57.667],[0,0],[0,89.333],[32.667,63.667],[58.667,33.667],[52.667,0],[42.667,-15],[31.333,-29.333],[0,0],[-21.333,89.333],[-59.667,66.333],[-92.667,36.333],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-0.97,15.27],"ix":2},"s":{"a":0,"k":[1.49,1.49],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":4,"k":{"a":0,"k":[0.0866,0,0.565,0.973,0.3961,0.695,0.385,0.993,0.6299,0.95,0.255,0.38,0.8862,0.96,0.326,0.036,0.0866,0.99999,0.397,0.99999,0.6299,0.999995,0.88621,1],"ix":2}},"s":{"a":0,"k":[-117.95,0],"ix":2},"e":{"a":0,"k":[117.95,0],"ix":2},"t":1},{"ty":"tr","s":{"a":1,"k":[{"t":72,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":132,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":72,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":73,"s":[1.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":74,"s":[3.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":75,"s":[5],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":76,"s":[6.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":77,"s":[8.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":78,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":79,"s":[11.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":80,"s":[13.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":81,"s":[15],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":82,"s":[16.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":83,"s":[18.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":84,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":85,"s":[21.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":86,"s":[23.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":87,"s":[25],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":88,"s":[26.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":89,"s":[28.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":90,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":91,"s":[31.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":92,"s":[33.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":93,"s":[35],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":94,"s":[36.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":95,"s":[38.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":96,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":97,"s":[41.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":98,"s":[43.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":99,"s":[45],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":100,"s":[46.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":101,"s":[48.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":102,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":103,"s":[51.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":104,"s":[53.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":105,"s":[55],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":106,"s":[56.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":107,"s":[58.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":108,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":109,"s":[61.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":110,"s":[63.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":111,"s":[65],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":112,"s":[66.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":113,"s":[68.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":114,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":115,"s":[71.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":116,"s":[73.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":117,"s":[75],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":118,"s":[76.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":119,"s":[78.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":120,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":121,"s":[81.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":122,"s":[83.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":123,"s":[85],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":124,"s":[86.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":125,"s":[88.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":126,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":127,"s":[91.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":128,"s":[93.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":129,"s":[95],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":130,"s":[96.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":131,"s":[98.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":132,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":20,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":15,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[202,0],[202,-2048],[986,-2048],[1355.5,-1978.5],[1576.5,-1789],[1650,-1517],[1602,-1303.5],[1473,-1160],[1292,-1081],[1292,-1061],[1501.5,-994.5],[1666,-824],[1730,-558],[1653,-272.5],[1421,-73],[1031,0],[202,0]],"i":[[0,0],[0,0],[0,0],[-98.333,-46.333],[-49,-80],[0,-101.333],[32,-59],[54,-36.667],[66.667,-16],[0,0],[-67,-40.333],[-42.667,-73.333],[0,-104],[51.333,-84.333],[103.333,-48.667],[156.667,0],[0,0]],"o":[[0,0],[0,0],[148,0],[98.333,46.333],[49,80],[0,83.333],[-32,59],[-54,36.667],[0,0],[72.667,4],[67,40.333],[42.667,73.333],[0,106],[-51.333,84.333],[-103.333,48.667],[0,0],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[573,-310],[972,-310],[1263.5,-387.5],[1353,-587],[1308,-751.5],[1180,-867.5],[982,-910],[573,-910],[573,-310]],"i":[[0,0],[0,0],[-59.667,51.667],[0,81.333],[30,49],[55.333,28.333],[76.667,0],[0,0],[0,0]],"o":[[0,0],[134.667,0],[59.667,-51.667],[0,-60.667],[-30,-49],[-55.333,-28.333],[0,0],[0,0],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[573,-1177],[940,-1177],[1113,-1212.5],[1234.5,-1313.5],[1279,-1469],[1195.5,-1665],[948,-1742],[573,-1742],[573,-1177]],"i":[[0,0],[0,0],[-51.333,23.667],[-29.667,43.667],[0,60],[55.667,51.333],[109.333,0],[0,0],[0,0]],"o":[[0,0],[64,0],[51.333,-23.667],[29.667,-43.667],[0,-79.333],[-55.667,-51.333],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-28.58,15.27],"ix":2},"s":{"a":0,"k":[1.49,1.49],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":4,"k":{"a":0,"k":[0.0866,0,0.565,0.973,0.3961,0.695,0.385,0.993,0.6299,0.95,0.255,0.38,0.8862,0.96,0.326,0.036,0.0866,0.99999,0.397,0.99999,0.6299,0.999995,0.88621,1],"ix":2}},"s":{"a":0,"k":[-117.95,0],"ix":2},"e":{"a":0,"k":[117.95,0],"ix":2},"t":1},{"ty":"tr","s":{"a":1,"k":[{"t":54,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":114,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":54,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":55,"s":[1.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":56,"s":[3.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":57,"s":[5],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":58,"s":[6.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":59,"s":[8.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":60,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":61,"s":[11.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":62,"s":[13.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":63,"s":[15],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":64,"s":[16.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":65,"s":[18.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":66,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":67,"s":[21.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":68,"s":[23.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":69,"s":[25],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":70,"s":[26.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":71,"s":[28.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":72,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":73,"s":[31.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":74,"s":[33.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":75,"s":[35],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":76,"s":[36.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":77,"s":[38.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":78,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":79,"s":[41.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":80,"s":[43.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":81,"s":[45],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":82,"s":[46.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":83,"s":[48.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":84,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":85,"s":[51.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":86,"s":[53.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":87,"s":[55],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":88,"s":[56.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":89,"s":[58.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":90,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":91,"s":[61.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":92,"s":[63.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":93,"s":[65],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":94,"s":[66.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":95,"s":[68.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":96,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":97,"s":[71.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":98,"s":[73.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":99,"s":[75],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":100,"s":[76.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":101,"s":[78.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":102,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":103,"s":[81.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":104,"s":[83.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":105,"s":[85],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":106,"s":[86.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":107,"s":[88.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":108,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":109,"s":[91.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":110,"s":[93.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":111,"s":[95],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":112,"s":[96.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":113,"s":[98.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":114,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":21,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":15,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[186,0],[186,-1536],[537,-1536],[537,-1280],[553,-1280],[697.5,-1485.5],[932,-1558],[999.5,-1555.5],[1062,-1548],[1062,-1215],[989.5,-1229.5],[894,-1236],[716.5,-1193.5],[593,-1076],[548,-903],[548,0],[186,0]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[-68.333,48.333],[-88,0],[-25,-1.667],[-16.667,-3.333],[0,0],[33,4.333],[30.667,0],[52.333,-28.333],[30,-50],[0,-65.333],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[28,-88.667],[68.333,-48.333],[20,0],[25,1.667],[0,0],[-15.333,-5.333],[-33,-4.333],[-66,0],[-52.333,28.333],[-30,50],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-45.24,15.27],"ix":2},"s":{"a":0,"k":[1.49,1.49],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":4,"k":{"a":0,"k":[0.0866,0,0.565,0.973,0.3961,0.695,0.385,0.993,0.6299,0.95,0.255,0.38,0.8862,0.96,0.326,0.036,0.0866,0.99999,0.397,0.99999,0.6299,0.999995,0.88621,1],"ix":2}},"s":{"a":0,"k":[-117.95,0],"ix":2},"e":{"a":0,"k":[117.95,0],"ix":2},"t":1},{"ty":"tr","s":{"a":1,"k":[{"t":36,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":96,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":36,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":37,"s":[1.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":38,"s":[3.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":39,"s":[5],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":40,"s":[6.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":41,"s":[8.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":42,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":43,"s":[11.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":44,"s":[13.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":45,"s":[15],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":46,"s":[16.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":47,"s":[18.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":48,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":49,"s":[21.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":50,"s":[23.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":51,"s":[25],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":52,"s":[26.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":53,"s":[28.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":54,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":55,"s":[31.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":56,"s":[33.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":57,"s":[35],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":58,"s":[36.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":59,"s":[38.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":60,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":61,"s":[41.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":62,"s":[43.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":63,"s":[45],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":64,"s":[46.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":65,"s":[48.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":66,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":67,"s":[51.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":68,"s":[53.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":69,"s":[55],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":70,"s":[56.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":71,"s":[58.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":72,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":73,"s":[61.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":74,"s":[63.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":75,"s":[65],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":76,"s":[66.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":77,"s":[68.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":78,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":79,"s":[71.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":80,"s":[73.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":81,"s":[75],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":82,"s":[76.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":83,"s":[78.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":84,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":85,"s":[81.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":86,"s":[83.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":87,"s":[85],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":88,"s":[86.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":89,"s":[88.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":90,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":91,"s":[91.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":92,"s":[93.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":93,"s":[95],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":94,"s":[96.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":95,"s":[98.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":96,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":22,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":15,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[186,0],[186,-1536],[548,-1536],[548,0],[186,0]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[368.5,-1754],[220,-1811.81],[158,-1950.81],[219.88,-2089.5],[368,-2147],[516.5,-2089.19],[578,-1950.19],[516.5,-1811.5],[368.5,-1754]],"i":[[0,0],[41.333,38.543],[0,54.124],[-41.25,38.333],[-57.5,0],[-41,-38.543],[0,-54.124],[41,-38.333],[57.667,0]],"o":[[-57.667,0],[-41.333,-38.543],[0,-54.124],[41.25,-38.333],[58,0],[41,38.543],[0,54.124],[-41,38.333],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-56.18,15.27],"ix":2},"s":{"a":0,"k":[1.49,1.49],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":4,"k":{"a":0,"k":[0.0866,0,0.565,0.973,0.3961,0.695,0.385,0.993,0.6299,0.95,0.255,0.38,0.8862,0.96,0.326,0.036,0.0866,0.99999,0.397,0.99999,0.6299,0.999995,0.88621,1],"ix":2}},"s":{"a":0,"k":[-117.95,0],"ix":2},"e":{"a":0,"k":[117.95,0],"ix":2},"t":1},{"ty":"tr","s":{"a":1,"k":[{"t":18,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":78,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":18,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":19,"s":[1.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":20,"s":[3.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":21,"s":[5],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":22,"s":[6.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":23,"s":[8.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":24,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":25,"s":[11.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":26,"s":[13.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":27,"s":[15],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":28,"s":[16.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":29,"s":[18.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":30,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":31,"s":[21.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":32,"s":[23.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":33,"s":[25],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":34,"s":[26.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":35,"s":[28.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":36,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":37,"s":[31.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":38,"s":[33.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":39,"s":[35],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":40,"s":[36.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":41,"s":[38.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":42,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":43,"s":[41.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":44,"s":[43.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":45,"s":[45],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":46,"s":[46.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":47,"s":[48.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":48,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":49,"s":[51.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":50,"s":[53.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":51,"s":[55],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":52,"s":[56.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":53,"s":[58.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":54,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":55,"s":[61.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":56,"s":[63.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":57,"s":[65],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":58,"s":[66.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":59,"s":[68.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":60,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":61,"s":[71.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":62,"s":[73.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":63,"s":[75],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":64,"s":[76.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":65,"s":[78.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":66,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":67,"s":[81.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":68,"s":[83.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":69,"s":[85],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":70,"s":[86.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":71,"s":[88.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":72,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":73,"s":[91.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":74,"s":[93.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":75,"s":[95],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":76,"s":[96.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":77,"s":[98.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":78,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"ind":23,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"parent":15,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[465,0],[69,0],[790,-2048],[1248,-2048],[1970,0],[1574,0],[1027,-1628],[1011,-1628],[465,0]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[478,-803],[1558,-803],[1558,-505],[478,-505],[478,-803]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[-86.73,15.27],"ix":2},"s":{"a":0,"k":[1.49,1.49],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":4,"k":{"a":0,"k":[0.0866,0,0.565,0.973,0.3961,0.695,0.385,0.993,0.6299,0.95,0.255,0.38,0.8862,0.96,0.326,0.036,0.0866,0.99999,0.397,0.99999,0.6299,0.999995,0.88621,1],"ix":2}},"s":{"a":0,"k":[-117.95,0],"ix":2},"e":{"a":0,"k":[117.95,0],"ix":2},"t":1},{"ty":"tr","s":{"a":1,"k":[{"t":0,"s":[200,200],"o":{"x":[0],"y":[0]},"i":{"x":[0.58],"y":[1]}},{"t":60,"s":[100,100]}],"ix":2},"o":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":1,"s":[1.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":2,"s":[3.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":3,"s":[5],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":4,"s":[6.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":5,"s":[8.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":6,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":7,"s":[11.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":8,"s":[13.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":9,"s":[15],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":10,"s":[16.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":11,"s":[18.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":12,"s":[20],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":13,"s":[21.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":14,"s":[23.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":15,"s":[25],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":16,"s":[26.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":17,"s":[28.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":18,"s":[30],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":19,"s":[31.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":20,"s":[33.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":21,"s":[35],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":22,"s":[36.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":23,"s":[38.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":24,"s":[40],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":25,"s":[41.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":26,"s":[43.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":27,"s":[45],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":28,"s":[46.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":29,"s":[48.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":30,"s":[50],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":31,"s":[51.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":32,"s":[53.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":33,"s":[55],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":34,"s":[56.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":35,"s":[58.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":36,"s":[60],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":37,"s":[61.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":38,"s":[63.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":39,"s":[65],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":40,"s":[66.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":41,"s":[68.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":42,"s":[70],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":43,"s":[71.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":44,"s":[73.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":45,"s":[75],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":46,"s":[76.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":47,"s":[78.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":48,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":49,"s":[81.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":50,"s":[83.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":51,"s":[85],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":52,"s":[86.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":53,"s":[88.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":54,"s":[90],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":55,"s":[91.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":56,"s":[93.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":57,"s":[95],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":58,"s":[96.67],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":59,"s":[98.33],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":60,"s":[100]}],"ix":2}}]}],"ip":0,"op":201,"st":0},{"ddd":0,"refId":"1","ind":24,"ty":2,"sr":1,"ks":{"p":{"a":0,"k":[187.47,395.94],"ix":2},"a":{"a":0,"k":[768,512],"ix":2},"s":{"a":1,"k":[{"t":0,"s":[0,0],"h":1},{"t":1,"s":[0,0],"h":1},{"t":2,"s":[0.01,0.01],"h":1},{"t":3,"s":[0.01,0.01],"h":1},{"t":4,"s":[0.02,0.02],"h":1},{"t":5,"s":[0.04,0.04],"h":1},{"t":6,"s":[0.05,0.05],"h":1},{"t":7,"s":[0.07,0.07],"h":1},{"t":8,"s":[0.09,0.09],"h":1},{"t":9,"s":[0.12,0.12],"h":1},{"t":10,"s":[0.14,0.14],"h":1},{"t":11,"s":[0.17,0.17],"h":1},{"t":12,"s":[0.21,0.21],"h":1},{"t":13,"s":[0.24,0.24],"h":1},{"t":14,"s":[0.28,0.28],"h":1},{"t":15,"s":[0.32,0.32],"h":1},{"t":16,"s":[0.36,0.36],"h":1},{"t":17,"s":[0.41,0.41],"h":1},{"t":18,"s":[0.46,0.46],"h":1},{"t":19,"s":[0.51,0.51],"h":1},{"t":20,"s":[0.56,0.56],"h":1},{"t":21,"s":[0.62,0.62],"h":1},{"t":22,"s":[0.67,0.67],"h":1},{"t":23,"s":[0.73,0.73],"h":1},{"t":24,"s":[0.8,0.8],"h":1},{"t":25,"s":[0.86,0.86],"h":1},{"t":26,"s":[0.93,0.93],"h":1},{"t":27,"s":[1,1],"h":1},{"t":28,"s":[1.07,1.07],"h":1},{"t":29,"s":[1.15,1.15],"h":1},{"t":30,"s":[1.22,1.22],"h":1},{"t":31,"s":[1.3,1.3],"h":1},{"t":32,"s":[1.38,1.38],"h":1},{"t":33,"s":[1.47,1.47],"h":1},{"t":34,"s":[1.55,1.55],"h":1},{"t":35,"s":[1.64,1.64],"h":1},{"t":36,"s":[1.73,1.73],"h":1},{"t":37,"s":[1.82,1.82],"h":1},{"t":38,"s":[1.92,1.92],"h":1},{"t":39,"s":[2.01,2.01],"h":1},{"t":40,"s":[2.11,2.11],"h":1},{"t":41,"s":[2.21,2.21],"h":1},{"t":42,"s":[2.32,2.32],"h":1},{"t":43,"s":[2.42,2.42],"h":1},{"t":44,"s":[2.53,2.53],"h":1},{"t":45,"s":[2.64,2.64],"h":1},{"t":46,"s":[2.75,2.75],"h":1},{"t":47,"s":[2.86,2.86],"h":1},{"t":48,"s":[2.97,2.97],"h":1},{"t":49,"s":[3.09,3.09],"h":1},{"t":50,"s":[3.21,3.21],"h":1},{"t":51,"s":[3.33,3.33],"h":1},{"t":52,"s":[3.45,3.45],"h":1},{"t":53,"s":[3.57,3.57],"h":1},{"t":54,"s":[3.7,3.7],"h":1},{"t":55,"s":[3.82,3.82],"h":1},{"t":56,"s":[3.95,3.95],"h":1},{"t":57,"s":[4.08,4.08],"h":1},{"t":58,"s":[4.22,4.22],"h":1},{"t":59,"s":[4.35,4.35],"h":1},{"t":60,"s":[4.48,4.48],"h":1},{"t":61,"s":[4.62,4.62],"h":1},{"t":62,"s":[4.76,4.76],"h":1},{"t":63,"s":[4.9,4.9],"h":1},{"t":64,"s":[5.04,5.04],"h":1},{"t":65,"s":[5.19,5.19],"h":1},{"t":66,"s":[5.33,5.33],"h":1},{"t":67,"s":[5.48,5.48],"h":1},{"t":68,"s":[5.62,5.62],"h":1},{"t":69,"s":[5.77,5.77],"h":1},{"t":70,"s":[5.93,5.93],"h":1},{"t":71,"s":[6.08,6.08],"h":1},{"t":72,"s":[6.23,6.23],"h":1},{"t":73,"s":[6.39,6.39],"h":1},{"t":74,"s":[6.54,6.54],"h":1},{"t":75,"s":[6.7,6.7],"h":1},{"t":76,"s":[6.86,6.86],"h":1},{"t":77,"s":[7.02,7.02],"h":1},{"t":78,"s":[7.18,7.18],"h":1},{"t":79,"s":[7.34,7.34],"h":1},{"t":80,"s":[7.51,7.51],"h":1},{"t":81,"s":[7.67,7.67],"h":1},{"t":82,"s":[7.84,7.84],"h":1},{"t":83,"s":[8.01,8.01],"h":1},{"t":84,"s":[8.18,8.18],"h":1},{"t":85,"s":[8.35,8.35],"h":1},{"t":86,"s":[8.52,8.52],"h":1},{"t":87,"s":[8.69,8.69],"h":1},{"t":88,"s":[8.86,8.86],"h":1},{"t":89,"s":[9.04,9.04],"h":1},{"t":90,"s":[9.21,9.21],"h":1},{"t":91,"s":[9.39,9.39],"h":1},{"t":92,"s":[9.57,9.57],"h":1},{"t":93,"s":[9.74,9.74],"h":1},{"t":94,"s":[9.92,9.92],"h":1},{"t":95,"s":[10.1,10.1],"h":1},{"t":96,"s":[10.28,10.28],"h":1},{"t":97,"s":[10.47,10.47],"h":1},{"t":98,"s":[10.65,10.65],"h":1},{"t":99,"s":[10.83,10.83],"h":1},{"t":100,"s":[11.02,11.02],"h":1},{"t":101,"s":[11.2,11.2],"h":1},{"t":102,"s":[11.39,11.39],"h":1},{"t":103,"s":[11.57,11.57],"h":1},{"t":104,"s":[11.76,11.76],"h":1},{"t":105,"s":[11.95,11.95],"h":1},{"t":106,"s":[12.13,12.13],"h":1},{"t":107,"s":[12.32,12.32],"h":1},{"t":108,"s":[12.51,12.51],"h":1},{"t":109,"s":[12.7,12.7],"h":1},{"t":110,"s":[12.89,12.89],"h":1},{"t":111,"s":[13.08,13.08],"h":1},{"t":112,"s":[13.27,13.27],"h":1},{"t":113,"s":[13.46,13.46],"h":1},{"t":114,"s":[13.66,13.66],"h":1},{"t":115,"s":[13.85,13.85],"h":1},{"t":116,"s":[14.04,14.04],"h":1},{"t":117,"s":[14.23,14.23],"h":1},{"t":118,"s":[14.43,14.43],"h":1},{"t":119,"s":[14.62,14.62],"h":1},{"t":120,"s":[14.81,14.81],"h":1},{"t":121,"s":[15,15],"h":1},{"t":122,"s":[15.2,15.2],"h":1},{"t":123,"s":[15.39,15.39],"h":1},{"t":124,"s":[15.58,15.58],"h":1},{"t":125,"s":[15.78,15.78],"h":1},{"t":126,"s":[15.97,15.97],"h":1},{"t":127,"s":[16.16,16.16],"h":1},{"t":128,"s":[16.36,16.36],"h":1},{"t":129,"s":[16.55,16.55],"h":1},{"t":130,"s":[16.74,16.74],"h":1},{"t":131,"s":[16.93,16.93],"h":1},{"t":132,"s":[17.12,17.12],"h":1},{"t":133,"s":[17.32,17.32],"h":1},{"t":134,"s":[17.51,17.51],"h":1},{"t":135,"s":[17.7,17.7],"h":1},{"t":136,"s":[17.89,17.89],"h":1},{"t":137,"s":[18.07,18.07],"h":1},{"t":138,"s":[18.26,18.26],"h":1},{"t":139,"s":[18.45,18.45],"h":1},{"t":140,"s":[18.64,18.64],"h":1},{"t":141,"s":[18.82,18.82],"h":1},{"t":142,"s":[19.01,19.01],"h":1},{"t":143,"s":[19.19,19.19],"h":1},{"t":144,"s":[19.37,19.37],"h":1},{"t":145,"s":[19.55,19.55],"h":1},{"t":146,"s":[19.74,19.74],"h":1},{"t":147,"s":[19.91,19.91],"h":1},{"t":148,"s":[20.09,20.09],"h":1},{"t":149,"s":[20.27,20.27],"h":1},{"t":150,"s":[20.44,20.44],"h":1},{"t":151,"s":[20.61,20.61],"h":1},{"t":152,"s":[20.79,20.79],"h":1},{"t":153,"s":[20.95,20.95],"h":1},{"t":154,"s":[21.12,21.12],"h":1},{"t":155,"s":[21.29,21.29],"h":1},{"t":156,"s":[21.45,21.45],"h":1},{"t":157,"s":[21.61,21.61],"h":1},{"t":158,"s":[21.77,21.77],"h":1},{"t":159,"s":[21.92,21.92],"h":1},{"t":160,"s":[22.08,22.08],"h":1},{"t":161,"s":[22.23,22.23],"h":1},{"t":162,"s":[22.37,22.37],"h":1},{"t":163,"s":[22.52,22.52],"h":1},{"t":164,"s":[22.66,22.66],"h":1},{"t":165,"s":[22.8,22.8],"h":1},{"t":166,"s":[22.93,22.93],"h":1},{"t":167,"s":[23.06,23.06],"h":1},{"t":168,"s":[23.19,23.19],"h":1},{"t":169,"s":[23.31,23.31],"h":1},{"t":170,"s":[23.42,23.42],"h":1},{"t":171,"s":[23.54,23.54],"h":1},{"t":172,"s":[23.64,23.64],"h":1},{"t":173,"s":[23.74,23.74],"h":1},{"t":174,"s":[23.84,23.84],"h":1},{"t":175,"s":[23.93,23.93],"h":1},{"t":176,"s":[24.01,24.01],"h":1},{"t":177,"s":[24.09,24.09],"h":1},{"t":178,"s":[24.16,24.16],"h":1},{"t":179,"s":[24.22,24.22],"h":1},{"t":180,"s":[24.28,24.28],"h":1},{"t":181,"s":[24.32,24.32],"h":1},{"t":182,"s":[24.36,24.36],"h":1},{"t":183,"s":[24.39,24.39],"h":1},{"t":184,"s":[24.4,24.4],"h":1},{"t":185,"s":[24.41,24.41],"h":1}],"ix":2},"r":{"a":1,"k":[{"t":0,"s":[45],"o":{"x":[0.4],"y":[0]},"i":{"x":[0.8],"y":[1]}},{"t":185,"s":[0]}],"ix":2},"o":{"a":0,"k":100,"ix":2}},"ao":0,"ip":0,"op":201,"st":0}]} \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..3967ddc5b3a698f692f26e01df06519423233b89 GIT binary patch literal 96470 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 NQ=^0X^7pug{tt#YG{gV^ literal 0 HcmV?d00001 diff --git a/app/src/main/java/com/darkxvenom/airbeats/App.kt b/app/src/main/java/com/darkxvenom/airbeats/App.kt new file mode 100644 index 00000000..56d91ffa --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/App.kt @@ -0,0 +1,189 @@ +package com.darkxvenom.airbeats + +import android.app.Application +import android.content.Context +import android.os.Build +import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT +import androidx.datastore.preferences.core.edit +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache +import coil.request.CachePolicy +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.YouTubeLocale +import com.darkxvenom.airbeats.kugou.KuGou +import com.darkxvenom.airbeats.constants.AccountChannelHandleKey +import com.darkxvenom.airbeats.constants.AccountEmailKey +import com.darkxvenom.airbeats.constants.AccountNameKey +import com.darkxvenom.airbeats.constants.ContentCountryKey +import com.darkxvenom.airbeats.constants.ContentLanguageKey +import com.darkxvenom.airbeats.constants.CountryCodeToName +import com.darkxvenom.airbeats.constants.DataSyncIdKey +import com.darkxvenom.airbeats.constants.InnerTubeCookieKey +import com.darkxvenom.airbeats.constants.LanguageCodeToName +import com.darkxvenom.airbeats.constants.MaxImageCacheSizeKey +import com.darkxvenom.airbeats.constants.ProxyEnabledKey +import com.darkxvenom.airbeats.constants.ProxyTypeKey +import com.darkxvenom.airbeats.constants.ProxyUrlKey +import com.darkxvenom.airbeats.constants.SYSTEM_DEFAULT +import com.darkxvenom.airbeats.constants.UseLoginForBrowse +import com.darkxvenom.airbeats.constants.VisitorDataKey +import com.darkxvenom.airbeats.extensions.toEnum +import com.darkxvenom.airbeats.extensions.toInetSocketAddress +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.HiltAndroidApp +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.net.Proxy +import java.util.Locale + +@HiltAndroidApp +class App : Application(), ImageLoaderFactory { + @OptIn(DelicateCoroutinesApi::class) + override fun onCreate() { + super.onCreate() + instance = this; + Timber.plant(Timber.DebugTree()) + + val locale = Locale.getDefault() + val languageTag = locale.toLanguageTag().replace("-Hant", "") // replace zh-Hant-* to zh-* + YouTube.locale = YouTubeLocale( + gl = dataStore[ContentCountryKey]?.takeIf { it != SYSTEM_DEFAULT } + ?: locale.country.takeIf { it in CountryCodeToName } + ?: "US", + hl = dataStore[ContentLanguageKey]?.takeIf { it != SYSTEM_DEFAULT } + ?: locale.language.takeIf { it in LanguageCodeToName } + ?: languageTag.takeIf { it in LanguageCodeToName } + ?: "en" + ) + if (languageTag == "zh-TW") { + KuGou.useTraditionalChinese = true + } + + if (dataStore[ProxyEnabledKey] == true) { + try { + YouTube.proxy = Proxy( + dataStore[ProxyTypeKey].toEnum(defaultValue = Proxy.Type.HTTP), + dataStore[ProxyUrlKey]!!.toInetSocketAddress() + ) + } catch (e: Exception) { + Toast.makeText(this, "Failed to parse proxy url.", LENGTH_SHORT).show() + reportException(e) + } + } + + if (dataStore[UseLoginForBrowse] != false) { + YouTube.useLoginForBrowse = true + } + + GlobalScope.launch { + dataStore.data + .map { it[VisitorDataKey] } + .distinctUntilChanged() + .collect { visitorData -> + YouTube.visitorData = visitorData + ?.takeIf { it != "null" } // Previously visitorData was sometimes saved as "null" due to a bug + ?: YouTube.visitorData().onFailure { + withContext(Dispatchers.Main) { + Toast.makeText(this@App, "Failed to get visitorData.", LENGTH_SHORT) + .show() + } + reportException(it) + }.getOrNull()?.also { newVisitorData -> + dataStore.edit { settings -> + settings[VisitorDataKey] = newVisitorData + } + } + } + } + GlobalScope.launch { + dataStore.data + .map { it[DataSyncIdKey] } + .distinctUntilChanged() + .collect { dataSyncId -> + YouTube.dataSyncId = dataSyncId?.let { + /* + * Workaround to avoid breaking older installations that have a dataSyncId + * that contains "||" in it. + * If the dataSyncId ends with "||" and contains only one id, then keep the + * id before the "||". + * If the dataSyncId contains "||" and is not at the end, then keep the + * second id. + * This is needed to keep using the same account as before. + */ + it.takeIf { !it.contains("||") } + ?: it.takeIf { it.endsWith("||") }?.substringBefore("||") + ?: it.substringAfter("||") + } + } + } + GlobalScope.launch { + dataStore.data + .map { it[InnerTubeCookieKey] } + .distinctUntilChanged() + .collect { cookie -> + try { + YouTube.cookie = cookie + } catch (e: Exception) { + // we now allow user input now, here be the demons. This serves as a last ditch effort to avoid a crash loop + Timber.e("Could not parse cookie. Clearing existing cookie. %s", e.message) + forgetAccount(this@App) + } + } + } + } + + override fun newImageLoader(): ImageLoader { + val cacheSize = dataStore[MaxImageCacheSizeKey] + + // will crash app if you set to 0 after cache starts being used + if (cacheSize == 0) { + return ImageLoader.Builder(this) + .crossfade(true) + .respectCacheHeaders(false) + .allowHardware(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + .diskCachePolicy(CachePolicy.DISABLED) + .build() + } + + return ImageLoader.Builder(this) + .crossfade(true) + .respectCacheHeaders(false) + .allowHardware(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + .diskCache( + DiskCache.Builder() + .directory(cacheDir.resolve("coil")) + .maxSizeBytes((cacheSize ?: 512) * 1024 * 1024L) + .build() + ) + .build() + } + + companion object { + lateinit var instance: App + private set + + fun forgetAccount(context: Context) { + runBlocking { + context.dataStore.edit { settings -> + settings.remove(InnerTubeCookieKey) + settings.remove(VisitorDataKey) + settings.remove(DataSyncIdKey) + settings.remove(AccountNameKey) + settings.remove(AccountEmailKey) + settings.remove(AccountChannelHandleKey) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/MainActivity.kt b/app/src/main/java/com/darkxvenom/airbeats/MainActivity.kt new file mode 100644 index 00000000..37a376ed --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/MainActivity.kt @@ -0,0 +1,1829 @@ +package com.darkxvenom.airbeats + +import android.Manifest +import com.darkxvenom.airbeats.ui.component.LocalUserName +import android.annotation.SuppressLint +import androidx.compose.animation.core.LinearEasing +import androidx.core.app.ActivityCompat +import com.google.firebase.messaging.FirebaseMessaging +import androidx.compose.ui.geometry.Offset +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.material3.Icon +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalDensity +import android.content.ActivityNotFoundException +import androidx.compose.ui.draw.scale +import androidx.compose.ui.text.TextStyle +import androidx.compose.animation.core.animateFloatAsState +import android.content.ComponentName +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.ExperimentalMaterial3Api +import kotlin.math.roundToInt +import androidx.compose.foundation.layout.statusBarsPadding +import android.content.Context +import androidx.compose.runtime.derivedStateOf +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.provider.Settings +import android.util.Log +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +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.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +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.BoxWithConstraints +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.add +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.navigationBarsPadding +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.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.compositionLocalOf +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.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +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.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import com.darkxvenom.airbeats.ui.component.CircleIconButton +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastFirstOrNull +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.core.util.Consumer +import androidx.core.view.WindowCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.lifecycleScope +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import kotlin.math.roundToInt +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.constants.AppBarHeight +import com.darkxvenom.airbeats.constants.DarkModeKey +import com.darkxvenom.airbeats.constants.DefaultOpenTabKey +import com.darkxvenom.airbeats.constants.DisableScreenshotKey +import com.darkxvenom.airbeats.constants.DynamicThemeKey +import com.darkxvenom.airbeats.constants.MiniPlayerHeight +import com.darkxvenom.airbeats.constants.NavigationBarAnimationSpec +import com.darkxvenom.airbeats.constants.NavigationBarHeight +import com.darkxvenom.airbeats.constants.PauseSearchHistoryKey +import com.darkxvenom.airbeats.constants.PlayerBackgroundStyle +import androidx.compose.foundation.Image +import com.darkxvenom.airbeats.constants.PlayerBackgroundStyleKey +import com.darkxvenom.airbeats.constants.PureBlackKey +import com.darkxvenom.airbeats.constants.SearchSource +import com.darkxvenom.airbeats.constants.SearchSourceKey +import com.darkxvenom.airbeats.constants.SlimNavBarKey +import com.darkxvenom.airbeats.constants.StopMusicOnTaskClearKey +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.SearchHistory +import com.darkxvenom.airbeats.extensions.toEnum +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.DownloadUtil +import com.darkxvenom.airbeats.playback.MusicService +import com.darkxvenom.airbeats.playback.MusicService.MusicBinder +import com.darkxvenom.airbeats.playback.PlayerConnection +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.AvatarPreferenceManager +import com.darkxvenom.airbeats.ui.component.AvatarSelection +import com.darkxvenom.airbeats.ui.component.BottomSheetMenu +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.CurvedBottomNavigationBar +import com.darkxvenom.airbeats.ui.component.CurvedBottomNavigationItem +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.LocaleManager +import com.darkxvenom.airbeats.ui.component.Lyrics +import com.darkxvenom.airbeats.ui.component.NamePreferenceManager +import com.darkxvenom.airbeats.ui.component.NameProvider +import com.darkxvenom.airbeats.ui.component.SwitchPreference +import com.darkxvenom.airbeats.ui.component.TopSearch +import com.darkxvenom.airbeats.ui.component.rememberBottomSheetState +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerTheme +import com.darkxvenom.airbeats.ui.menu.YouTubeSongMenu +import com.darkxvenom.airbeats.ui.player.BottomSheetPlayer +import com.darkxvenom.airbeats.ui.screens.HomeScreen +import com.darkxvenom.airbeats.ui.screens.Screens +import com.darkxvenom.airbeats.ui.screens.navigationBuilder +import com.darkxvenom.airbeats.ui.screens.search.LocalSearchScreen +import com.darkxvenom.airbeats.ui.screens.search.OnlineSearchScreen +import com.darkxvenom.airbeats.ui.screens.settings.DarkMode +import com.darkxvenom.airbeats.ui.screens.settings.NavigationTab +import com.darkxvenom.airbeats.ui.theme.ColorSaver +import com.darkxvenom.airbeats.ui.theme.DefaultThemeColor +import com.darkxvenom.airbeats.ui.theme.AirBeatsTheme +import com.darkxvenom.airbeats.ui.theme.extractThemeColor +import com.darkxvenom.airbeats.ui.utils.appBarScrollBehavior +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.ui.utils.resetHeightOffset +import com.darkxvenom.airbeats.utils.SyncUtils +import com.darkxvenom.airbeats.utils.Updater +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.utils.reportException +import com.darkxvenom.airbeats.viewmodels.NewReleaseViewModel +import com.valentinilk.shimmer.LocalShimmerTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import timber.log.Timber +import java.net.URL +import java.net.URLDecoder +import java.net.URLEncoder +import javax.inject.Inject +import kotlin.math.absoluteValue +import kotlin.time.Duration.Companion.days + +@Suppress("DEPRECATION", "ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + @Inject + lateinit var database: MusicDatabase + + @Inject + lateinit var downloadUtil: DownloadUtil + + @Inject + lateinit var syncUtils: SyncUtils + + @Inject + lateinit var namePreferenceManager: NamePreferenceManager + + private var playerConnection by mutableStateOf(null) + private val serviceConnection = + object : ServiceConnection { + override fun onServiceConnected( + name: ComponentName?, + service: IBinder?, + ) { + if (service is MusicBinder) { + playerConnection = + PlayerConnection(this@MainActivity, service, database, lifecycleScope) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + playerConnection?.dispose() + playerConnection = null + } + } + + private var latestVersionName by mutableStateOf(BuildConfig.VERSION_NAME) + + override fun onStart() { + super.onStart() + startService(Intent(this, MusicService::class.java)) + bindService( + Intent(this, MusicService::class.java), + serviceConnection, + Context.BIND_AUTO_CREATE + ) + } + + override fun onStop() { + unbindService(serviceConnection) + super.onStop() + } + + override fun onDestroy() { + super.onDestroy() + if (dataStore.get( + StopMusicOnTaskClearKey, + false + ) && playerConnection?.isPlaying?.value == true && isFinishing + ) { + stopService(Intent(this, MusicService::class.java)) + unbindService(serviceConnection) + playerConnection = null + } + } + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext( + LocaleManager.getInstance(newBase).applyLocaleToContext(newBase) + ) + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + + super.onCreate(savedInstanceState) + + // 🔔 Notification permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1 + ) + } + + // 🔥 Get FCM token + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + if (task.isSuccessful) { + Log.d("FCM_TOKEN", task.result) + } else { + Log.e("FCM_TOKEN", "Token failed") + } + } + + // 🔥 Subscribe all users + FirebaseMessaging.getInstance().subscribeToTopic("all_users") + .addOnCompleteListener { + if (it.isSuccessful) { + Log.d("FCM", "Subscribed to all_users") + } else { + Log.e("FCM", "Subscription failed") + } + } + + window.decorView.layoutDirection = View.LAYOUT_DIRECTION_LTR + WindowCompat.setDecorFitsSystemWindows(window, false) + + lifecycleScope.launch { + dataStore.data + .map { it[DisableScreenshotKey] ?: false } + .distinctUntilChanged() + .collectLatest { + if (it) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } + + intent?.let { handlevideoIdIntent(it) } + + setContent { + LaunchedEffect(Unit) { + if (System.currentTimeMillis() - Updater.lastCheckTime > 1.days.inWholeMilliseconds) { + Updater.getLatestVersionName().onSuccess { + latestVersionName = it + } + } + } + + var showSplash by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + delay(2000) + showSplash = false + } + + var showFullscreenLyrics by remember { mutableStateOf(false) } + + val enableDynamicTheme by rememberPreference(DynamicThemeKey, defaultValue = true) + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + + val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) + val isSystemInDarkTheme = isSystemInDarkTheme() + val useDarkTheme = + remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + LaunchedEffect(useDarkTheme) { + setSystemBarAppearance(useDarkTheme) + } + var themeColor by rememberSaveable(stateSaver = ColorSaver) { + mutableStateOf(DefaultThemeColor) + } + + LaunchedEffect(playerConnection, enableDynamicTheme, isSystemInDarkTheme) { + val playerConnection = playerConnection + if (!enableDynamicTheme || playerConnection == null) { + themeColor = DefaultThemeColor + return@LaunchedEffect + } + playerConnection.service.currentMediaMetadata.collectLatest { song -> + themeColor = + if (song != null) { + withContext(Dispatchers.IO) { + val result = + imageLoader.execute( + ImageRequest + .Builder(this@MainActivity) + .data(song.thumbnailUrl) + .allowHardware(false) + .build(), + ) + (result.drawable as? BitmapDrawable)?.bitmap?.extractThemeColor() + ?: DefaultThemeColor + } + } else { + DefaultThemeColor + } + } + } + + AirBeatsTheme( + darkTheme = useDarkTheme, + pureBlack = pureBlack, + themeColor = themeColor, + ) { + + if (showSplash) { + HeadphoneSplashScreen() + } else { + + NameProvider( + namePreferenceManager = namePreferenceManager + ) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + .background(Color.Transparent), + ) + { + val focusManager = LocalFocusManager.current + val density = LocalDensity.current + val windowsInsets = WindowInsets.systemBars + val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } + val bottomInsetDp = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val (previousTab) = rememberSaveable { mutableStateOf("home") } + + val navigationItems = remember { Screens.MainScreens } + val (slimNav) = rememberPreference(SlimNavBarKey, defaultValue = false) + val defaultOpenTab = + remember { + dataStore[DefaultOpenTabKey].toEnum(defaultValue = NavigationTab.HOME) + } + val tabOpenedFromShortcut = + remember { + when (intent?.action) { + ACTION_LIBRARY -> NavigationTab.LIBRARY + ACTION_EXPLORE -> NavigationTab.EXPLORE + else -> null + } + } + + val topLevelScreens = + listOf( + Screens.Home.route, + Screens.Explore.route, + Screens.Library.route, + "settings", + ) + + val (query, onQueryChange) = + rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + + var active by rememberSaveable { + mutableStateOf(false) + } + + val onActiveChange: (Boolean) -> Unit = { newActive -> + active = newActive + if (!newActive) { + focusManager.clearFocus() + if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { + onQueryChange(TextFieldValue()) + } + } + } + + var searchSource by rememberEnumPreference(SearchSourceKey, SearchSource.ONLINE) + + val searchBarFocusRequester = remember { FocusRequester() } + + val onSearch: (String) -> Unit = { + if (it.isNotEmpty()) { + onActiveChange(false) + navController.navigate("search/${URLEncoder.encode(it, "UTF-8")}") + if (dataStore[PauseSearchHistoryKey] != true) { + database.query { + insert(SearchHistory(query = it)) + } + } + } + } + + var openSearchImmediately: Boolean by remember { + mutableStateOf(intent?.action == ACTION_SEARCH) + } + + val shouldShowSearchBar = + remember(active, navBackStackEntry) { + active || + navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } || + navBackStackEntry?.destination?.route?.startsWith("search/") == true + } + + val shouldShowNavigationBar = + remember(navBackStackEntry, active) { + navBackStackEntry?.destination?.route == null || + navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } && + !active + } + + val navigationBarHeight by animateDpAsState( + targetValue = if (shouldShowNavigationBar) NavigationBarHeight else 0.dp, + animationSpec = NavigationBarAnimationSpec, + label = "", + ) + + val playerBottomSheetState = + rememberBottomSheetState( + dismissedBound = 0.dp, + collapsedBound = bottomInset + (if (shouldShowNavigationBar) NavigationBarHeight - 16.dp else 0.dp) + MiniPlayerHeight, + expandedBound = maxHeight, + ) + + val playerAwareWindowInsets = + remember( + bottomInset, + shouldShowNavigationBar, + playerBottomSheetState.isDismissed + ) { + var bottom = bottomInset + if (shouldShowNavigationBar) bottom += NavigationBarHeight - 16.dp + if (!playerBottomSheetState.isDismissed) bottom += MiniPlayerHeight + windowsInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .add(WindowInsets(top = AppBarHeight, bottom = bottom)) + } + + appBarScrollBehavior( + canScroll = { + navBackStackEntry?.destination?.route?.startsWith("search/") == false && + (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) + } + ) + + val searchBarScrollBehavior = + appBarScrollBehavior( + canScroll = { + navBackStackEntry?.destination?.route?.startsWith("search/") == false && + (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) + }, + ) + val topAppBarScrollBehavior = + appBarScrollBehavior( + canScroll = { + navBackStackEntry?.destination?.route?.startsWith("search/") == false && + (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) + }, + ) + + LaunchedEffect(navBackStackEntry) { + if (navBackStackEntry?.destination?.route?.startsWith("search/") == true) { + val searchQuery = + withContext(Dispatchers.IO) { + if (navBackStackEntry + ?.arguments + ?.getString("query")!! + .contains("%") + ) { + navBackStackEntry?.arguments?.getString("query")!! + } else { + URLDecoder.decode( + navBackStackEntry?.arguments?.getString("query")!!, + "UTF-8" + ) + } + } + onQueryChange( + TextFieldValue( + searchQuery, + TextRange(searchQuery.length) + ) + ) + if (searchQuery.isEmpty()) { + onActiveChange(true) + } + } else if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { + onQueryChange(TextFieldValue()) + } + searchBarScrollBehavior.state.resetHeightOffset() + topAppBarScrollBehavior.state.resetHeightOffset() + } + LaunchedEffect(active) { + if (active) { + searchBarScrollBehavior.state.resetHeightOffset() + topAppBarScrollBehavior.state.resetHeightOffset() + searchBarFocusRequester.requestFocus() + } + } + + LaunchedEffect(playerConnection) { + val player = playerConnection?.player ?: return@LaunchedEffect + if (player.currentMediaItem == null) { + if (!playerBottomSheetState.isDismissed) { + playerBottomSheetState.dismiss() + } + } else { + if (playerBottomSheetState.isDismissed) { + playerBottomSheetState.collapseSoft() + } + } + } + + DisposableEffect(playerConnection, playerBottomSheetState) { + val player = + playerConnection?.player ?: return@DisposableEffect onDispose { } + val listener = + object : Player.Listener { + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && + mediaItem != null && + playerBottomSheetState.isDismissed + ) { + playerBottomSheetState.collapseSoft() + } + } + } + player.addListener(listener) + onDispose { + player.removeListener(listener) + } + } + + var shouldShowTopBar by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(navBackStackEntry) { + shouldShowTopBar = + !active && navBackStackEntry?.destination?.route in topLevelScreens && navBackStackEntry?.destination?.route != "settings" + } + + val coroutineScope = rememberCoroutineScope() + var sharedSong: SongItem? by remember { + mutableStateOf(null) + } + DisposableEffect(Unit) { + val listener = + Consumer { intent -> + val uri = intent.data ?: intent.extras?.getString(Intent.EXTRA_TEXT) + ?.toUri() ?: return@Consumer + when (val path = uri.pathSegments.firstOrNull()) { + "playlist" -> + uri.getQueryParameter("list")?.let { playlistId -> + if (playlistId.startsWith("OLAK5uy_")) { + coroutineScope.launch { + YouTube + .albumSongs(playlistId) + .onSuccess { songs -> + songs.firstOrNull()?.album?.id?.let { browseId -> + navController.navigate("album/$browseId") + } + }.onFailure { + reportException(it) + } + } + } else { + navController.navigate("online_playlist/$playlistId") + } + } + + "browse" -> + uri.lastPathSegment?.let { browseId -> + navController.navigate("album/$browseId") + } + + "channel", "c" -> + uri.lastPathSegment?.let { artistId -> + navController.navigate("artist/$artistId") + } + + else -> + when { + path == "watch" -> uri.getQueryParameter("v") + uri.host == "youtu.be" -> path + else -> null + }?.let { videoId -> + coroutineScope.launch { + withContext(Dispatchers.IO) { + YouTube.queue(listOf(videoId)) + }.onSuccess { + playerConnection?.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = it.firstOrNull()?.id), + it.firstOrNull()?.toMediaMetadata() + ) + ) + }.onFailure { + reportException(it) + } + } + } + } + } + + addOnNewIntentListener(listener) + onDispose { removeOnNewIntentListener(listener) } + } + + val currentTitle = remember(navBackStackEntry) { + when (navBackStackEntry?.destination?.route) { + Screens.Home.route -> R.string.home + Screens.Explore.route -> R.string.explore + Screens.Library.route -> R.string.filter_library + else -> null + } + } + val baseBg = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer + val insetBg = if (playerBottomSheetState.progress > 0f) Color.Transparent else baseBg + + CompositionLocalProvider( + LocalDatabase provides database, + LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.surface), + LocalPlayerConnection provides playerConnection, + LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, + LocalDownloadUtil provides downloadUtil, + LocalShimmerTheme provides ShimmerTheme, + LocalSyncUtils provides syncUtils, + ) { + var showRealNavBar by remember { mutableStateOf(false) } + var playIntroAnimation by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + delay(200) + showRealNavBar = true + delay(600) + playIntroAnimation = false + } + + Scaffold( + containerColor = Color.Transparent, + topBar = { + val isSearchRoute = + navBackStackEntry?.destination?.route?.startsWith("search/") == true + + if (active || isSearchRoute) { + TopSearch( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + placeholder = { + Text( + text = stringResource( + when (searchSource) { + SearchSource.LOCAL -> R.string.search_library + SearchSource.ONLINE -> R.string.search_yt_music + } + ), + ) + }, + leadingIcon = { + IconButton( + onClick = { + when { + active -> onActiveChange(false) + !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } -> { + navController.navigateUp() + } + else -> onActiveChange(true) + } + }, + onLongClick = { + when { + active -> {} + !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } -> { + navController.backToMain() + } + else -> {} + } + }, + ) { + Icon( + painterResource( + if (active || + !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } + ) { + R.drawable.arrow_back + } else { + R.drawable.search + } + ), + contentDescription = null, + ) + } + }, + trailingIcon = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (active) { + if (query.text.isNotEmpty()) { + IconButton( + onClick = { + onQueryChange(TextFieldValue("")) + }, + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + } + IconButton( + onClick = { + searchSource = + if (searchSource == SearchSource.ONLINE) { + SearchSource.LOCAL + } else { + SearchSource.ONLINE + } + }, + ) { + Icon( + painter = painterResource( + when (searchSource) { + SearchSource.LOCAL -> R.drawable.library_music + SearchSource.ONLINE -> R.drawable.language + } + ), + contentDescription = stringResource( + when (searchSource) { + SearchSource.LOCAL -> R.string.search_online + SearchSource.ONLINE -> R.string.search_library + } + ), + ) + } + } + } + }, + modifier = Modifier + .focusRequester(searchBarFocusRequester) + .align(Alignment.TopCenter) + .fillMaxWidth(), + focusRequester = searchBarFocusRequester + ) { + Crossfade( + targetState = searchSource, + label = "search_content_transition", + modifier = Modifier + .fillMaxSize() + .padding( + bottom = if (!playerBottomSheetState.isDismissed) { + MiniPlayerHeight + } else { + 0.dp + } + ) + .navigationBarsPadding(), + ) { currentSearchSource -> + when (currentSearchSource) { + SearchSource.LOCAL -> LocalSearchScreen( + query = query.text, + navController = navController, + onDismiss = { onActiveChange(false) }, + pureBlack = pureBlack, + ) + SearchSource.ONLINE -> OnlineSearchScreen( + query = query.text, + onQueryChange = onQueryChange, + navController = navController, + onSearch = { searchQuery -> + try { + val encodedQuery = URLEncoder.encode(searchQuery, "UTF-8") + navController.navigate("search/$encodedQuery") + if (dataStore[PauseSearchHistoryKey] != true) { + database.query { + insert(SearchHistory(query = searchQuery)) + } + } + } catch (e: Exception) { + Log.e("SearchNavigation", "Error navigating to search: ${e.message}", e) + } + }, + onDismiss = { onActiveChange(false) }, + ) + } + } + } + } + }, + bottomBar = { + Box { + + val currentRoute = navBackStackEntry?.destination?.route + + val isTopLevel = + currentRoute == Screens.Home.route || + currentRoute == Screens.Explore.route || + currentRoute == Screens.Library.route + + BottomSheetPlayer( + state = playerBottomSheetState, + navController = navController, + onOpenFullscreenLyrics = { + showFullscreenLyrics = true + }, + modifier = Modifier + .fillMaxSize() + .offset(y = 0.dp) + ) + + AnimatedVisibility( + visible = showFullscreenLyrics, + enter = slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(300) + ) + fadeIn(animationSpec = tween(300)), + exit = slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(300) + ) + fadeOut(animationSpec = tween(300)) + ) { + val playerConnection = LocalPlayerConnection.current + val mediaMetadata by playerConnection?.mediaMetadata?.collectAsState() + ?: return@AnimatedVisibility + + if (mediaMetadata != null) { + Lyrics( + sliderPositionProvider = { null }, + onNavigateBack = { + showFullscreenLyrics = false + }, + modifier = Modifier.fillMaxSize() + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + Text("No hay canción reproduciéndose") + } + } + } + + val configuration = LocalConfiguration.current + val isTabletLandscape = configuration.screenWidthDp >= 600 && + configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + val isSearchActive = + active || navBackStackEntry?.destination?.route?.startsWith("search/") == true + + val shouldShowBottomNav = + isTopLevel && + !isSearchActive && + playerBottomSheetState.progress < 0.95f + + if (shouldShowBottomNav) { + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 6.dp) + .fillMaxWidth(0.88f) + .height(NavigationBarHeight - 16.dp), + contentAlignment = Alignment.Center + ) { + + val offsetY by animateDpAsState( + targetValue = if (playIntroAnimation) 120.dp else 0.dp, + animationSpec = tween(600), + label = "nav_offset" + ) + + val scale by animateFloatAsState( + targetValue = if (playIntroAnimation) 0.3f else 1f, + animationSpec = tween(600), + label = "nav_scale" + ) + + val alpha by animateFloatAsState( + targetValue = if (playIntroAnimation) 0.6f else 1f, + animationSpec = tween(600), + label = "nav_alpha" + ) + + // ═══════════════════════════════════════════════════════ + // LIQUID GLASS NAV BAR — works on both dark & light themes + // Strategy: + // • BLUR layer — Android 12+ real blur, older = tinted fallback + // • TINT layer — semi-transparent surface tint (adapts to theme) + // • HIGHLIGHT — white-to-transparent vertical gradient (top sheen) + // • BORDER — dual-stroke: outer white shimmer + inner bright line + // • SHADOW — outer drop shadow for lift / separation + // The tint uses colorScheme.surface so it is warm-white in light + // mode and dark-grey in dark mode, making the bar always visible. + // ═══════════════════════════════════════════════════════ + + val surfaceColor = MaterialTheme.colorScheme.surface + val onSurfaceColor = MaterialTheme.colorScheme.onSurface + + val curvedItems = navigationItems.map { screen -> + CurvedBottomNavigationItem( + iconInactive = screen.iconIdInactive, + iconActive = screen.iconIdActive, + titleId = screen.titleId + ) + } + + val selectedIndex = navigationItems.indexOfFirst { screen -> + navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true + }.takeIf { it >= 0 } ?: 0 + + var lastTapTime by remember { mutableLongStateOf(0L) } + var lastTappedIcon by remember { mutableStateOf(null) } + var navigateToExplore by remember { mutableStateOf(false) } + + CurvedBottomNavigationBar( + items = curvedItems, + selectedIndex = selectedIndex, + onItemSelected = { index -> + val screen = navigationItems[index] + val isSelected = index == selectedIndex + + val currentTapTime = System.currentTimeMillis() + val timeSinceLastTap = currentTapTime - lastTapTime + val isDoubleTap = + screen.titleId == R.string.explore && + lastTappedIcon == R.string.explore && + timeSinceLastTap < 300L + + lastTapTime = currentTapTime + lastTappedIcon = screen.titleId + + if (screen.titleId == R.string.explore) { + if (isDoubleTap) { + onActiveChange(true) + navigateToExplore = false + } else { + navigateToExplore = true + coroutineScope.launch { + delay(300L) + if (navigateToExplore) { + navigateToScreen(navController, screen) + } + } + } + } else { + if (isSelected) { + navController.currentBackStackEntry?.savedStateHandle?.set("scrollToTop", true) + coroutineScope.launch { + searchBarScrollBehavior.state.resetHeightOffset() + } + } else { + navigateToScreen(navController, screen) + } + } + }, + modifier = Modifier + .fillMaxSize() + .offset(y = offsetY) + .scale(scale) + .alpha(alpha) + ) + } + + if (playerBottomSheetState.isDismissed) { + Box( + modifier = Modifier + .background(insetBg) + .fillMaxWidth() + .align(Alignment.BottomCenter) + .height(bottomInsetDp) + ) + } + } else { + if (playerBottomSheetState.isDismissed) { + Box( + modifier = Modifier + .background(insetBg) + .fillMaxWidth() + .align(Alignment.BottomCenter) + .height(bottomInsetDp) + ) + } + } + } + }, + modifier = Modifier + .fillMaxSize() + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) + .background(MaterialTheme.colorScheme.surface) + + ) { + var transitionDirection = + AnimatedContentTransitionScope.SlideDirection.Left + + if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { + if (navigationItems.fastAny { it.route == previousTab }) { + val curIndex = navigationItems.indexOf( + navigationItems.fastFirstOrNull { + it.route == navBackStackEntry?.destination?.route + } + ) + val prevIndex = navigationItems.indexOf( + navigationItems.fastFirstOrNull { + it.route == previousTab + } + ) + if (prevIndex > curIndex) + AnimatedContentTransitionScope.SlideDirection.Right.also { + transitionDirection = it + } + } + } + + NavHost( + navController = navController, + startDestination = when (tabOpenedFromShortcut ?: defaultOpenTab) { + NavigationTab.HOME -> Screens.Home + NavigationTab.EXPLORE -> Screens.Explore + NavigationTab.LIBRARY -> Screens.Library + }.route, + + enterTransition = { + if (initialState.destination.route in topLevelScreens && + targetState.destination.route in topLevelScreens + ) { + fadeIn(spring(dampingRatio = Spring.DampingRatioNoBouncy)) + } else { + fadeIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + } + }, + + exitTransition = { + if (initialState.destination.route in topLevelScreens && + targetState.destination.route in topLevelScreens + ) { + fadeOut(spring(dampingRatio = Spring.DampingRatioNoBouncy)) + } else { + fadeOut(spring(dampingRatio = Spring.DampingRatioLowBouncy)) + + slideOutHorizontally( + targetOffsetX = { -it / 5 }, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow) + ) + } + }, + + popEnterTransition = { + if ((initialState.destination.route in topLevelScreens || + initialState.destination.route?.startsWith("search/") == true) && + targetState.destination.route in topLevelScreens + ) { + fadeIn(spring(dampingRatio = Spring.DampingRatioNoBouncy)) + } else { + fadeIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + } + }, + + popExitTransition = { + if ((initialState.destination.route in topLevelScreens || + initialState.destination.route?.startsWith("search/") == true) && + targetState.destination.route in topLevelScreens + ) { + fadeOut(spring(dampingRatio = Spring.DampingRatioNoBouncy)) + } else { + fadeOut(spring(dampingRatio = Spring.DampingRatioLowBouncy)) + + slideOutHorizontally( + targetOffsetX = { it / 5 }, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow) + ) + } + }, + + modifier = Modifier.nestedScroll( + if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } || + navBackStackEntry?.destination?.route?.startsWith("search/") == true + ) { + searchBarScrollBehavior.nestedScrollConnection + } else { + topAppBarScrollBehavior.nestedScrollConnection + } + ) + ) { + navigationBuilder( + navController, + topAppBarScrollBehavior, + latestVersionName + ) + } + } + + BottomSheetMenu( + state = LocalMenuState.current, + modifier = Modifier.align(Alignment.BottomCenter) + ) + + sharedSong?.let { song -> + playerConnection?.let { + Dialog( + onDismissRequest = { sharedSong = null }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = RoundedCornerShape(16.dp), + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = { sharedSong = null }, + ) + } + } + } + } + } + } + + LaunchedEffect(shouldShowSearchBar, openSearchImmediately) { + if (shouldShowSearchBar && openSearchImmediately) { + onActiveChange(true) + try { + delay(100) + searchBarFocusRequester.requestFocus() + } catch (_: Exception) { + } + openSearchImmediately = false + } + } + } + } + } + } + } + } + + private fun navigateToScreen( + navController: NavHostController, + screen: Screens + ) { + navController.navigate(screen.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + + private fun handlevideoIdIntent(intent: Intent) { + val uri = intent.data ?: intent.extras?.getString(Intent.EXTRA_TEXT)?.toUri() ?: return + when { + uri.pathSegments.firstOrNull() == "watch" -> uri.getQueryParameter("v") + uri.host == "youtu.be" -> uri.pathSegments.firstOrNull() + else -> null + }?.let { videoId -> + lifecycleScope.launch { + withContext(Dispatchers.IO) { + YouTube.queue(listOf(videoId)) + }.onSuccess { + playerConnection?.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = it.firstOrNull()?.id), + it.firstOrNull()?.toMediaMetadata() + ) + ) + }.onFailure { + reportException(it) + } + } + } + } + + @SuppressLint("ObsoleteSdkInt") + private fun setSystemBarAppearance(isDark: Boolean) { + window.statusBarColor = android.graphics.Color.TRANSPARENT + window.navigationBarColor = android.graphics.Color.TRANSPARENT + + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = !isDark + isAppearanceLightNavigationBars = !isDark + } + } + + companion object { + const val ACTION_SEARCH = "com.darkxvenom.airbeats.action.SEARCH" + const val ACTION_EXPLORE = "com.darkxvenom.airbeats.action.EXPLORE" + const val ACTION_LIBRARY = "com.darkxvenom.airbeats.action.LIBRARY" + } +} + +val LocalDatabase = staticCompositionLocalOf { error("No database provided") } +val LocalPlayerConnection = + staticCompositionLocalOf { error("No PlayerConnection provided") } +val LocalPlayerAwareWindowInsets = + compositionLocalOf { error("No WindowInsets provided") } +val LocalDownloadUtil = staticCompositionLocalOf { error("No DownloadUtil provided") } +val LocalSyncUtils = staticCompositionLocalOf { error("No SyncUtils provided") } + + +@Composable +fun NotificationPermissionPreference() { + val context = LocalContext.current + var permissionGranted by remember { mutableStateOf(false) } + + val checkNotificationPermission = remember { + { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } else { + NotificationManagerCompat.from(context).areNotificationsEnabled() + } + } + } + + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + permissionGranted = isGranted + if (!isGranted) { + Log.d("NotificationPermission", "Permiso de notificaciones denegado") + } + } + + LaunchedEffect(Unit) { + permissionGranted = checkNotificationPermission() + } + + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + permissionGranted = checkNotificationPermission() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + SwitchPreference( + title = { Text(stringResource(R.string.notification)) }, + icon = { + Icon( + painter = painterResource( + id = if (permissionGranted) R.drawable.notification_on + else R.drawable.notification_off + ), + contentDescription = stringResource( + if (permissionGranted) R.string.notifications_enabled + else R.string.notifications_disabled + ) + ) + }, + checked = permissionGranted, + onCheckedChange = { checked -> + when { + checked && !permissionGranted -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + openNotificationSettings(context) + } + } + !checked && permissionGranted -> { + openNotificationSettings(context) + } + } + } + ) +} + +private fun openNotificationSettings(context: Context) { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + } + } else { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + } + + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.e("NotificationSettings", "No se pudo abrir configuración de notificaciones", e) + context.startActivity(Intent(Settings.ACTION_SETTINGS)) + } +} + +suspend fun checkForUpdates(): String? = withContext(Dispatchers.IO) { + try { + val url = URL("https://api.github.com/repos/d0x-dev/airbeats/releases/latest") + val connection = url.openConnection() + connection.connect() + val json = connection.getInputStream().bufferedReader().use { it.readText() } + val jsonObject = JSONObject(json) + return@withContext jsonObject.getString("tag_name") + } catch (e: Exception) { + e.printStackTrace() + return@withContext null + } +} + +fun isNewerVersion(remoteVersion: String, currentVersion: String): Boolean { + val remote = remoteVersion.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } + val current = currentVersion.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } + + for (i in 0 until maxOf(remote.size, current.size)) { + val r = remote.getOrNull(i) ?: 0 + val c = current.getOrNull(i) ?: 0 + if (r > c) return true + if (r < c) return false + } + return false +} + +@Composable +fun ProfileIconWithUpdateBadge( + currentVersion: String, + onProfileClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val avatarManager = remember { AvatarPreferenceManager(context) } + val currentSelection by avatarManager.getAvatarSelection.collectAsState(initial = AvatarSelection.Default) + var showUpdateBadge by remember { mutableStateOf(false) } + val updatedOnClick = rememberUpdatedState(onProfileClick) + + val infiniteTransition = rememberInfiniteTransition(label = "badge_animation") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "scale" + ) + + val alpha by infiniteTransition.animateFloat( + initialValue = 0.6f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + LaunchedEffect(currentVersion) { + try { + val latestVersion = withContext(Dispatchers.IO) { checkForUpdates() } + showUpdateBadge = latestVersion?.let { isNewerVersion(it, currentVersion) } ?: false + } catch (e: Exception) { + Timber.tag("ProfileIcon").e("Error checking for updates: ${e.message}") + } + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + try { + updatedOnClick.value() + } catch (e: Exception) { + e.printStackTrace() + } + } + ) { + Box(contentAlignment = Alignment.Center) { + when (currentSelection) { + is AvatarSelection.Custom -> { + AsyncImage( + model = ImageRequest.Builder(context) + .data((currentSelection as AvatarSelection.Custom).uri.toUri()) + .crossfade(true) + .error(R.drawable.person) + .placeholder(R.drawable.person) + .build(), + contentDescription = "Avatar personalizado", + modifier = modifier + .size(28.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } + is AvatarSelection.DiceBear -> { + AsyncImage( + model = ImageRequest.Builder(context) + .data((currentSelection as AvatarSelection.DiceBear).url) + .crossfade(true) + .error(R.drawable.person) + .placeholder(R.drawable.person) + .build(), + contentDescription = "Avatar DiceBear", + modifier = modifier + .size(28.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } + else -> { + Icon( + painter = painterResource(R.drawable.person), + contentDescription = "Avatar predeterminado", + modifier = modifier + ) + } + } + } + + if (showUpdateBadge) { + Box( + modifier = Modifier + .align(Alignment.Center) + .size(28.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .scale(scale) + .background( + brush = Brush.radialGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f * alpha), + Color.Transparent + ) + ), + shape = CircleShape + ) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + shape = CircleShape + ) + ) + Icon( + painter = painterResource(R.drawable.update), + contentDescription = "Actualización disponible", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(18.dp) + .align(Alignment.Center) + .scale(scale) + .alpha(alpha) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ModernHomeTopBar( + onSearchClick: () -> Unit, + onProfileClick: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior +) { + val context = LocalContext.current + val avatarManager = remember { AvatarPreferenceManager(context) } + val currentSelection by avatarManager + .getAvatarSelection + .collectAsState(initial = AvatarSelection.Default) + + val userName = LocalUserName.current + val displayName = if (userName.isNotEmpty()) userName else "Friend" + + val collapsedFraction by remember { + derivedStateOf { scrollBehavior.state.collapsedFraction } + } + + val contentAlpha = 1f - collapsedFraction + val yOffset = (-collapsedFraction * 120).roundToInt() + + Column( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 20.dp) + .padding(top = 24.dp, bottom = 12.dp) + .offset { + IntOffset(x = 0, y = yOffset) + } + ) { + Column(modifier = Modifier.alpha(contentAlpha)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth() + ) { + Column { + Box( + modifier = Modifier + .size(60.dp) + .clip(CircleShape) + .clickable { onProfileClick() }, + contentAlignment = Alignment.Center + ) { + when (currentSelection) { + is AvatarSelection.Custom -> { + AsyncImage( + model = (currentSelection as AvatarSelection.Custom).uri.toUri(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + is AvatarSelection.DiceBear -> { + AsyncImage( + model = (currentSelection as AvatarSelection.DiceBear).url, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + else -> { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf(Color(0xFF8E2DE2), Color(0xFF4A00E0)) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.person), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + Text( + text = "Hi, $displayName", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + CircleIconButton(icon = R.drawable.search, onClick = onSearchClick) + CircleIconButton(icon = R.drawable.favorite, onClick = { }) + } + } + } + } +} + +@Composable +fun HeadphoneSplashScreen() { + + val infiniteTransition = rememberInfiniteTransition(label = "bg_anim") + + val shift by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(6000, easing = LinearEasing) + ), + label = "shift" + ) + + val colors = MaterialTheme.colorScheme + + val animatedBackground = Brush.radialGradient( + colors = listOf( + Color(0x228E2DE2), + Color(0x224A00E0), + Color(0x22FF00C8), + colors.background + ), + center = Offset(shift % 600f, shift % 900f), + radius = 1200f + ) + + var startAnimation by remember { mutableStateOf(false) } + + val scale by animateFloatAsState( + targetValue = if (startAnimation) 1f else 0.9f, + animationSpec = tween(900, easing = FastOutSlowInEasing), + label = "scale" + ) + + val alpha by animateFloatAsState( + targetValue = if (startAnimation) 1f else 0f, + animationSpec = tween(900), + label = "alpha" + ) + + LaunchedEffect(Unit) { + startAnimation = true + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(animatedBackground), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + } + ) { + Image( + painter = painterResource(R.drawable.airbeats_logo), + contentDescription = null, + modifier = Modifier.size(220.dp) + ) + + Spacer(modifier = Modifier.height(30.dp)) + + Text( + text = "AirBeats", + fontSize = 42.sp, + fontWeight = FontWeight.ExtraBold, + letterSpacing = 2.sp, + style = TextStyle( + brush = Brush.linearGradient( + colors = listOf( + Color(0xFFE91E63), + Color(0xFFFFC107), + Color(0xFF2196F3) + ) + ) + ) + ) + } + } +} + +@Composable +fun AnimatedBar( + heightMultiplier: Float, + brush: Brush +) { + Box( + modifier = Modifier + .width(16.dp) + .height((120 * heightMultiplier).dp) + .clip(RoundedCornerShape(50)) + .background(brush) + .shadow( + elevation = 25.dp, + shape = RoundedCornerShape(50) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/MusicWidget.kt b/app/src/main/java/com/darkxvenom/airbeats/MusicWidget.kt new file mode 100644 index 00000000..cfcdf946 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/MusicWidget.kt @@ -0,0 +1,308 @@ +package com.darkxvenom.airbeats + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.widget.RemoteViews +import androidx.core.graphics.drawable.toBitmap +import androidx.media3.common.Player +import coil.ImageLoader +import coil.request.ImageRequest +import com.darkxvenom.airbeats.playback.PlayerConnection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit + +class MusicWidget : AppWidgetProvider() { + + private val handler = Handler(Looper.getMainLooper()) + + // ✅ FIXED: nullable instead of lateinit + private var runnable: Runnable? = null + private var isUpdating = false + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + appWidgetIds.forEach { appWidgetId -> + updateWidget(context, appWidgetManager, appWidgetId) + } + startProgressUpdater(context) + } + + override fun onEnabled(context: Context) { + startProgressUpdater(context) + } + + override fun onDisabled(context: Context) { + stopProgressUpdater() + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + when (intent.action) { + ACTION_PLAY_PAUSE -> { + PlayerConnection.instance?.togglePlayPause() + updateAllWidgets(context) + } + + ACTION_PREV -> { + PlayerConnection.instance?.seekToPrevious() + updateAllWidgets(context) + } + + ACTION_NEXT -> { + PlayerConnection.instance?.seekToNext() + updateAllWidgets(context) + } + + ACTION_SHUFFLE -> { + PlayerConnection.instance?.toggleShuffle() + updateAllWidgets(context) + } + + ACTION_LIKE -> { + PlayerConnection.instance?.toggleLike() + updateAllWidgets(context) + } + + ACTION_REPLAY -> { + PlayerConnection.instance?.toggleReplayMode() + updateAllWidgets(context) + } + + ACTION_OPEN_APP -> { + openApp(context) + } + + ACTION_STATE_CHANGED, ACTION_UPDATE_PROGRESS -> { + updateAllWidgets(context) + } + } + } + + private fun openApp(context: Context) { + try { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + launchIntent?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + context.startActivity(this) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun startProgressUpdater(context: Context) { + if (isUpdating) return + + isUpdating = true + + runnable = Runnable { + val playerConnection = PlayerConnection.instance + val player = playerConnection?.player + + if (player != null && (player.isPlaying || player.playbackState == Player.STATE_READY)) { + updateAllWidgets(context) + runnable?.let { handler.postDelayed(it, 1000) } + } else { + updateAllWidgets(context) + runnable?.let { handler.postDelayed(it, 5000) } + } + } + + runnable?.let { handler.post(it) } + } + + private fun stopProgressUpdater() { + isUpdating = false + runnable?.let { + handler.removeCallbacks(it) + } + runnable = null + } + + companion object { + const val ACTION_PLAY_PAUSE = "com.darkxvenom.airbeats.ACTION_PLAY_PAUSE" + const val ACTION_PREV = "com.darkxvenom.airbeats.ACTION_PREV" + const val ACTION_NEXT = "com.darkxvenom.airbeats.ACTION_NEXT" + const val ACTION_SHUFFLE = "com.darkxvenom.airbeats.ACTION_SHUFFLE" + const val ACTION_LIKE = "com.darkxvenom.airbeats.ACTION_LIKE" + const val ACTION_REPLAY = "com.darkxvenom.airbeats.ACTION_REPLAY" + const val ACTION_OPEN_APP = "com.darkxvenom.airbeats.ACTION_OPEN_APP" + const val ACTION_STATE_CHANGED = "com.darkxvenom.airbeats.ACTION_STATE_CHANGED" + const val ACTION_UPDATE_PROGRESS = "com.darkxvenom.airbeats.ACTION_UPDATE_PROGRESS" + + fun updateAllWidgets(context: Context) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val widgetIds = appWidgetManager.getAppWidgetIds( + ComponentName(context, MusicWidget::class.java) + ) + if (widgetIds.isNotEmpty()) { + widgetIds.forEach { updateWidget(context, appWidgetManager, it) } + } + } + + private fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + ) { + val views = RemoteViews(context.packageName, R.layout.widget_music) + val playerConnection = PlayerConnection.instance + val player = playerConnection?.player + + setPendingIntents(context, views) + + player?.let { player -> + val songTitle = player.mediaMetadata.title?.toString() + ?: context.getString(R.string.song_title) + val artist = player.mediaMetadata.artist?.toString() + ?: context.getString(R.string.artist_name) + + views.setTextViewText(R.id.widget_song_title, songTitle) + views.setTextViewText(R.id.widget_artist, artist) + + val playPauseIcon = if (player.isPlaying) R.drawable.pause else R.drawable.play + views.setImageViewResource(R.id.widget_play_pause, playPauseIcon) + + val shuffleIcon = + if (player.shuffleModeEnabled) R.drawable.shuffle_on else R.drawable.shuffle + views.setImageViewResource(R.id.widget_shuffle, shuffleIcon) + + val likeIcon = + if (playerConnection.isCurrentSongLiked()) + R.drawable.favorite else R.drawable.favorite_border + views.setImageViewResource(R.id.widget_like, likeIcon) + + val currentPos = player.currentPosition + val duration = player.duration + + val currentTimeText = formatTime(currentPos) + val durationText = formatTime(duration) + + views.setTextViewText(R.id.widget_current_time, currentTimeText) + views.setTextViewText(R.id.widget_duration, durationText) + + val progress = + if (duration > 0 && duration != Long.MAX_VALUE) { + (currentPos * 100 / duration).toInt() + } else 0 + + if (duration > 0 && duration != Long.MAX_VALUE) { + views.setProgressBar(R.id.widget_progress_bar, 100, progress, false) + views.setViewVisibility(R.id.widget_progress_bar, android.view.View.VISIBLE) + views.setViewVisibility(R.id.widget_current_time, android.view.View.VISIBLE) + views.setViewVisibility(R.id.widget_duration, android.view.View.VISIBLE) + } else { + views.setViewVisibility(R.id.widget_progress_bar, android.view.View.GONE) + views.setViewVisibility(R.id.widget_current_time, android.view.View.GONE) + views.setViewVisibility(R.id.widget_duration, android.view.View.GONE) + } + + val playbackStateText = when { + player.repeatMode == Player.REPEAT_MODE_ONE -> context.getString(R.string.repeat_mode_one) + player.repeatMode == Player.REPEAT_MODE_ALL -> context.getString(R.string.repeat_mode_all) + else -> "" + } + + if (playbackStateText.isNotEmpty()) { + views.setTextViewText(R.id.widget_playback_state, playbackStateText) + views.setViewVisibility(R.id.widget_playback_state, android.view.View.VISIBLE) + } else { + views.setViewVisibility(R.id.widget_playback_state, android.view.View.GONE) + } + + val thumbnailUrl = player.mediaMetadata.artworkUri?.toString() + if (!thumbnailUrl.isNullOrEmpty()) { + CoroutineScope(Dispatchers.IO).launch { + try { + val request = ImageRequest.Builder(context) + .data(thumbnailUrl) + .size(160, 160) + .build() + val drawable = ImageLoader(context).execute(request).drawable + drawable?.let { + views.setImageViewBitmap(R.id.widget_album_art, it.toBitmap()) + appWidgetManager.partiallyUpdateAppWidget(appWidgetId, views) + } + } catch (e: Exception) { + views.setImageViewResource(R.id.widget_album_art, R.drawable.music_note) + appWidgetManager.partiallyUpdateAppWidget(appWidgetId, views) + } + } + } else { + views.setImageViewResource(R.id.widget_album_art, R.drawable.music_note) + } + + if (player.mediaItemCount == 0) { + views.setTextViewText(R.id.widget_song_title, context.getString(R.string.app_name)) + views.setTextViewText(R.id.widget_artist, context.getString(R.string.tap_to_open)) + views.setImageViewResource(R.id.widget_album_art, R.drawable.music_note) + } + } ?: run { + views.setTextViewText(R.id.widget_song_title, context.getString(R.string.app_name)) + views.setTextViewText(R.id.widget_artist, context.getString(R.string.tap_to_open)) + views.setImageViewResource(R.id.widget_album_art, R.drawable.music_note) + } + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + private fun setPendingIntents(context: Context, views: RemoteViews) { + val playPausePendingIntent = getBroadcastPendingIntent(context, ACTION_PLAY_PAUSE) + val prevPendingIntent = getBroadcastPendingIntent(context, ACTION_PREV) + val nextPendingIntent = getBroadcastPendingIntent(context, ACTION_NEXT) + val shufflePendingIntent = getBroadcastPendingIntent(context, ACTION_SHUFFLE) + val likePendingIntent = getBroadcastPendingIntent(context, ACTION_LIKE) + val openAppPendingIntent = getBroadcastPendingIntent(context, ACTION_OPEN_APP) + + views.setOnClickPendingIntent(R.id.widget_play_pause, playPausePendingIntent) + views.setOnClickPendingIntent(R.id.widget_prev, prevPendingIntent) + views.setOnClickPendingIntent(R.id.widget_next, nextPendingIntent) + views.setOnClickPendingIntent(R.id.widget_shuffle, shufflePendingIntent) + views.setOnClickPendingIntent(R.id.widget_like, likePendingIntent) + + views.setOnClickPendingIntent(R.id.widget_album_art, openAppPendingIntent) + views.setOnClickPendingIntent(R.id.widget_song_title, openAppPendingIntent) + views.setOnClickPendingIntent(R.id.widget_artist, openAppPendingIntent) + views.setOnClickPendingIntent(R.id.widget_progress_bar, openAppPendingIntent) + } + + private fun getBroadcastPendingIntent(context: Context, action: String): PendingIntent { + val intent = Intent(context, MusicWidget::class.java).apply { + this.action = action + } + + val flags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + + return PendingIntent.getBroadcast(context, action.hashCode(), intent, flags) + } + + @SuppressLint("DefaultLocale") + private fun formatTime(millis: Long): String { + return if (millis < 0 || millis == Long.MAX_VALUE) "0:00" else String.format( + "%d:%02d", + TimeUnit.MILLISECONDS.toMinutes(millis), + TimeUnit.MILLISECONDS.toSeconds(millis) - + TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(millis)) + ) + } + } +} + diff --git a/app/src/main/java/com/darkxvenom/airbeats/MyFirebaseMessagingService.kt b/app/src/main/java/com/darkxvenom/airbeats/MyFirebaseMessagingService.kt new file mode 100644 index 00000000..9e9cdb41 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/MyFirebaseMessagingService.kt @@ -0,0 +1,71 @@ +package com.darkxvenom.airbeats + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import kotlin.random.Random + +class MyFirebaseMessagingService : FirebaseMessagingService() { + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + + val title = remoteMessage.notification?.title ?: "AirBeats" + val body = remoteMessage.notification?.body ?: "New message" + + showNotification(title, body) + } + + override fun onNewToken(token: String) { + super.onNewToken(token) + // Optional: send token to your server + } + + private fun showNotification(title: String, message: String) { + + val channelId = "airbeats_channel" + val channelName = "AirBeats Notifications" + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // 🔔 Create channel (Android 8+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_HIGH + ) + notificationManager.createNotificationChannel(channel) + } + + // 🔥 Open app when clicked + val intent = packageManager.getLaunchIntentForPackage(packageName) + intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) + + // 🔔 Build notification + val notification = NotificationCompat.Builder(this, channelId) + .setContentTitle(title) + .setContentText(message) + .setSmallIcon(R.drawable.notification_on) // use your existing icon + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + // 🔥 Show notification + notificationManager.notify(Random.nextInt(), notification) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/constants/Dimensions.kt b/app/src/main/java/com/darkxvenom/airbeats/constants/Dimensions.kt new file mode 100644 index 00000000..d098feb3 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/constants/Dimensions.kt @@ -0,0 +1,34 @@ +package com.darkxvenom.airbeats.constants + + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +const val CONTENT_TYPE_HEADER = 0 +const val CONTENT_TYPE_LIST = 1 +const val CONTENT_TYPE_SONG = 2 +const val CONTENT_TYPE_ARTIST = 3 +const val CONTENT_TYPE_ALBUM = 4 +const val CONTENT_TYPE_PLAYLIST = 5 + +val NavigationBarHeight = 80.dp +val MiniPlayerHeight = 80.dp +val QueuePeekHeight = 64.dp +val AppBarHeight = 64.dp + +val ListItemHeight = 64.dp +val SuggestionItemHeight = 56.dp +val SearchFilterHeight = 48.dp +val ListThumbnailSize = 48.dp +val SmallGridThumbnailHeight = 104.dp +val GridThumbnailHeight = 128.dp +val AlbumThumbnailSize = 144.dp + +val ThumbnailCornerRadius = 6.dp +val ThumbnailCornerRadiusV2 = 6.dp +val ThumbnailCornerRadiusVariant = 25.dp +val PlayerHorizontalPadding = 32.dp + +val NavigationBarAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/constants/HistorySource.kt b/app/src/main/java/com/darkxvenom/airbeats/constants/HistorySource.kt new file mode 100644 index 00000000..6c712295 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/constants/HistorySource.kt @@ -0,0 +1,5 @@ +package com.darkxvenom.airbeats.constants + +enum class HistorySource { + LOCAL, REMOTE +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/constants/LibraryFilter.kt b/app/src/main/java/com/darkxvenom/airbeats/constants/LibraryFilter.kt new file mode 100644 index 00000000..c6e4a55b --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/constants/LibraryFilter.kt @@ -0,0 +1,10 @@ +package com.darkxvenom.airbeats.constants + +enum class LibraryFilter { + SONGS, + ARTISTS, + ALBUMS, + PLAYLISTS, + LIBRARY, + LOCAL, +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/constants/MediaSessionConstants.kt b/app/src/main/java/com/darkxvenom/airbeats/constants/MediaSessionConstants.kt new file mode 100644 index 00000000..7bbf78d2 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/constants/MediaSessionConstants.kt @@ -0,0 +1,15 @@ +package com.darkxvenom.airbeats.constants + +import android.os.Bundle +import androidx.media3.session.SessionCommand + +object MediaSessionConstants { + const val ACTION_TOGGLE_LIBRARY = "TOGGLE_LIBRARY" + const val ACTION_TOGGLE_LIKE = "TOGGLE_LIKE" + const val ACTION_TOGGLE_SHUFFLE = "TOGGLE_SHUFFLE" + const val ACTION_TOGGLE_REPEAT_MODE = "TOGGLE_REPEAT_MODE" + val CommandToggleLibrary = SessionCommand(ACTION_TOGGLE_LIBRARY, Bundle.EMPTY) + val CommandToggleLike = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY) + val CommandToggleShuffle = SessionCommand(ACTION_TOGGLE_SHUFFLE, Bundle.EMPTY) + val CommandToggleRepeatMode = SessionCommand(ACTION_TOGGLE_REPEAT_MODE, Bundle.EMPTY) +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/constants/PreferenceKeys.kt b/app/src/main/java/com/darkxvenom/airbeats/constants/PreferenceKeys.kt new file mode 100644 index 00000000..4d7069bb --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/constants/PreferenceKeys.kt @@ -0,0 +1,518 @@ +package com.darkxvenom.airbeats.constants + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import java.time.LocalDateTime +import java.time.ZoneOffset + +val DynamicThemeKey = booleanPreferencesKey("dynamicTheme") +val DarkModeKey = stringPreferencesKey("darkMode") + +val UserNameKey = stringPreferencesKey("user_name") + +val PureBlackKey = booleanPreferencesKey("pureBlack") +val DefaultOpenTabKey = stringPreferencesKey("defaultOpenTab") +val SlimNavBarKey = booleanPreferencesKey("slimNavBar") +val GridItemsSizeKey = stringPreferencesKey("gridItemSize") +val SliderStyleKey = stringPreferencesKey("sliderStyle") + +enum class SliderStyle { + DEFAULT, + SQUIGGLY, + SLIM, +} + +const val SYSTEM_DEFAULT = "SYSTEM_DEFAULT" +val ContentLanguageKey = stringPreferencesKey("contentLanguage") +val ContentCountryKey = stringPreferencesKey("contentCountry") +val EnableKugouKey = booleanPreferencesKey("enableKugou") +val EnableLrcLibKey = booleanPreferencesKey("enableLrclib") +val HideExplicitKey = booleanPreferencesKey("hideExplicit") +val LastNewReleaseCheckKey = longPreferencesKey("last_new_release_check") +val minPlaybackDurKey = intPreferencesKey("minPlaybackDur") +val ProxyEnabledKey = booleanPreferencesKey("proxyEnabled") +val ProxyUrlKey = stringPreferencesKey("proxyUrl") +val ProxyTypeKey = stringPreferencesKey("proxyType") +val YtmSyncKey = booleanPreferencesKey("ytmSync") + +val AudioQualityKey = stringPreferencesKey("audioQuality") + +enum class AudioQuality { + AUTO, + HIGH, + LOW, +} + +val PersistentQueueKey = booleanPreferencesKey("persistentQueue") +val SkipSilenceKey = booleanPreferencesKey("skipSilence") +val AudioNormalizationKey = booleanPreferencesKey("audioNormalization") +val AutoLoadMoreKey = booleanPreferencesKey("autoLoadMore") +val SimilarContent = booleanPreferencesKey("similarContent") +val AutoSkipNextOnErrorKey = booleanPreferencesKey("autoSkipNextOnError") +val StopMusicOnTaskClearKey = booleanPreferencesKey("stopMusicOnTaskClear") + +val MaxImageCacheSizeKey = intPreferencesKey("maxImageCacheSize") +val MaxSongCacheSizeKey = intPreferencesKey("maxSongCacheSize") + +val DisableLoadMoreWhenRepeatAllKey = booleanPreferencesKey("disableLoadMoreWhenRepeatAll") +val ScrobbleDelayPercentKey = floatPreferencesKey("scrobbleDelayPercent") +val ScrobbleMinSongDurationKey = intPreferencesKey("scrobbleMinSongDuration") +val ScrobbleDelaySecondsKey = intPreferencesKey("scrobbleDelaySeconds") +val EnableLastFMScrobblingKey = booleanPreferencesKey("enableLastFMScrobbling") +val LastFMUseNowPlaying = booleanPreferencesKey("lastFMUseNowPlaying") +val AudioOffload = booleanPreferencesKey("audioOffload") + +val PlayerTextAlignmentKey = stringPreferencesKey("playerTextAlignment") + +val RotateBackgroundKey = booleanPreferencesKey("rotate_background") + + +val SmallButtonsShapeKey = stringPreferencesKey("small_buttons_shape") +const val DefaultSmallButtonsShape = "Pill" + +val PauseListenHistoryKey = booleanPreferencesKey("pauseListenHistory") +val PauseSearchHistoryKey = booleanPreferencesKey("pauseSearchHistory") +val DisableScreenshotKey = booleanPreferencesKey("disableScreenshot") + +val DiscordTokenKey = stringPreferencesKey("discordToken") +val DiscordInfoDismissedKey = booleanPreferencesKey("discordInfoDismissed") +val DiscordUsernameKey = stringPreferencesKey("discordUsername") +val DiscordNameKey = stringPreferencesKey("discordName") +val EnableDiscordRPCKey = booleanPreferencesKey("discordRPCEnable") + +val ChipSortTypeKey = stringPreferencesKey("chipSortType") +val SongSortTypeKey = stringPreferencesKey("songSortType") +val SongSortDescendingKey = booleanPreferencesKey("songSortDescending") +val PlaylistSongSortTypeKey = stringPreferencesKey("playlistSongSortType") +val PlaylistSongSortDescendingKey = booleanPreferencesKey("playlistSongSortDescending") +val AutoPlaylistSongSortTypeKey = stringPreferencesKey("autoPlaylistSongSortType") +val AutoPlaylistSongSortDescendingKey = booleanPreferencesKey("autoPlaylistSongSortDescending") +val ArtistSortTypeKey = stringPreferencesKey("artistSortType") +val ArtistSortDescendingKey = booleanPreferencesKey("artistSortDescending") +val AlbumSortTypeKey = stringPreferencesKey("albumSortType") +val AlbumSortDescendingKey = booleanPreferencesKey("albumSortDescending") +val PlaylistSortTypeKey = stringPreferencesKey("playlistSortType") +val PlaylistSortDescendingKey = booleanPreferencesKey("playlistSortDescending") +val ArtistSongSortTypeKey = stringPreferencesKey("artistSongSortType") +val ArtistSongSortDescendingKey = booleanPreferencesKey("artistSongSortDescending") +val MixSortTypeKey = stringPreferencesKey("mixSortType") +val MixSortDescendingKey = booleanPreferencesKey("albumSortDescending") + +val SongFilterKey = stringPreferencesKey("songFilter") +val ArtistFilterKey = stringPreferencesKey("artistFilter") +val AlbumFilterKey = stringPreferencesKey("albumFilter") + + +val LyricsScrollKey = booleanPreferencesKey("lyricsScrollKey") + +val DiscordUseDetailsKey = booleanPreferencesKey("discordUseDetails") + + +val ArtistViewTypeKey = stringPreferencesKey("artistViewType") +val AlbumViewTypeKey = stringPreferencesKey("albumViewType") +val PlaylistViewTypeKey = stringPreferencesKey("playlistViewType") + +val PlaylistEditLockKey = booleanPreferencesKey("playlistEditLock") +val QuickPicksKey = stringPreferencesKey("discover") +val PreferredLyricsProviderKey = stringPreferencesKey("lyricsProvider") +val QueueEditLockKey = booleanPreferencesKey("queueEditLock") + +val LyricFontSizeKey = intPreferencesKey("lyricFontSize") +val fullScreenLyricsKey = booleanPreferencesKey("fullScreenLyrics") +val AnimateLyricsKey = booleanPreferencesKey("animate_lyrics") + + +val PlayPauseButtonShapeKey = stringPreferencesKey("playPauseButtonShape") +const val DefaultPlayPauseButtonShape = "Cookie9Sided" + +val MiniPlayerThumbnailShapeKey = stringPreferencesKey("miniPlayerThumbnailShape") +const val DefaultMiniPlayerThumbnailShape = "Circle" +enum class LibraryViewType { + LIST, + GRID, + ; + + fun toggle() = + when (this) { + LIST -> GRID + GRID -> LIST + } +} + +enum class SongFilter { + LIBRARY, + LIKED, + DOWNLOADED +} + +enum class ArtistFilter { + LIBRARY, + LIKED +} + +enum class AlbumFilter { + LIBRARY, + LIKED +} + +enum class SongSortType { + CREATE_DATE, + NAME, + ARTIST, + PLAY_TIME, +} + +enum class PlaylistSongSortType { + CUSTOM, + CREATE_DATE, + NAME, + ARTIST, + PLAY_TIME, +} + +enum class AutoPlaylistSongSortType { + CREATE_DATE, + NAME, + ARTIST, + PLAY_TIME, +} + +enum class ArtistSortType { + CREATE_DATE, + NAME, + SONG_COUNT, + PLAY_TIME, +} + +enum class ArtistSongSortType { + CREATE_DATE, + NAME, + PLAY_TIME, +} + +enum class AlbumSortType { + CREATE_DATE, + NAME, + ARTIST, + YEAR, + SONG_COUNT, + LENGTH, + PLAY_TIME, +} + +enum class PlaylistSortType { + CREATE_DATE, + NAME, + SONG_COUNT, + LAST_UPDATED, +} + +enum class MixSortType { + CREATE_DATE, + NAME, + LAST_UPDATED, +} + +enum class GridItemSize { + SMALL, + BIG, +} + +enum class MyTopFilter { + ALL_TIME, + DAY, + WEEK, + MONTH, + YEAR, + ; + + fun toTimeMillis(): Long = + when (this) { + DAY -> + LocalDateTime + .now() + .minusDays(1) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + WEEK -> + LocalDateTime + .now() + .minusWeeks(1) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + MONTH -> + LocalDateTime + .now() + .minusMonths(1) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + YEAR -> + LocalDateTime + .now() + .minusMonths(12) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + ALL_TIME -> 0 + } +} + +enum class QuickPicks { + QUICK_PICKS, + LAST_LISTEN, +} + +enum class PreferredLyricsProvider { + LRCLIB, + KUGOU, +} + +enum class PlayerBackgroundStyle { + DEFAULT, + GRADIENT, + BLUR, +} + + +enum class PlayerButtonsStyle { + DEFAULT, + PRIMARY, + TERTIARY +} + +val TopSize = stringPreferencesKey("topSize") +val HistoryDuration = floatPreferencesKey("historyDuration") + +val PlayerBackgroundStyleKey = stringPreferencesKey("playerBackgroundStyle") +val ShowLyricsKey = booleanPreferencesKey("showLyrics") +val LyricsTextPositionKey = stringPreferencesKey("lyricsTextPosition") +val LyricsClickKey = booleanPreferencesKey("lyricsClick") +val TranslateLyricsKey = booleanPreferencesKey("translateLyrics") + +val PlayerVolumeKey = floatPreferencesKey("playerVolume") +val RepeatModeKey = intPreferencesKey("repeatMode") +val PlayerButtonsStyleKey = stringPreferencesKey("player_buttons_style") + +val SearchSourceKey = stringPreferencesKey("searchSource") +val SwipeThumbnailKey = booleanPreferencesKey("swipeThumbnail") + +enum class SearchSource { + LOCAL, + ONLINE, + ; + + fun toggle() = + when (this) { + LOCAL -> ONLINE + ONLINE -> LOCAL + } +} + +val VisitorDataKey = stringPreferencesKey("visitorData") +val DataSyncIdKey = stringPreferencesKey("dataSyncId") +val AccountPhotoUrlKey = stringPreferencesKey("account_photo_url") +val InnerTubeCookieKey = stringPreferencesKey("innerTubeCookie") +val AccountNameKey = stringPreferencesKey("accountName") +val AccountEmailKey = stringPreferencesKey("accountEmail") +val AccountChannelHandleKey = stringPreferencesKey("accountChannelHandle") +val UseLoginForBrowse = booleanPreferencesKey("useLoginForBrowse") + +val LanguageCodeToName = + mapOf( + "af" to "Afrikaans", + "az" to "Azərbaycan", + "id" to "Bahasa Indonesia", + "ms" to "Bahasa Malaysia", + "ca" to "Català", + "cs" to "Čeština", + "da" to "Dansk", + "de" to "Deutsch", + "et" to "Eesti", + "en-GB" to "English (UK)", + "en" to "English (US)", + "es" to "Español (España)", + "es-419" to "Español (Latinoamérica)", + "eu" to "Euskara", + "fil" to "Filipino", + "fr" to "Français", + "fr-CA" to "Français (Canada)", + "gl" to "Galego", + "hr" to "Hrvatski", + "zu" to "IsiZulu", + "is" to "Íslenska", + "it" to "Italiano", + "sw" to "Kiswahili", + "lt" to "Lietuvių", + "hu" to "Magyar", + "nl" to "Nederlands", + "no" to "Norsk", + "or" to "Odia", + "uz" to "O‘zbe", + "pl" to "Polski", + "pt-PT" to "Português", + "pt" to "Português (Brasil)", + "ro" to "Română", + "sq" to "Shqip", + "sk" to "Slovenčina", + "sl" to "Slovenščina", + "fi" to "Suomi", + "sv" to "Svenska", + "bo" to "Tibetan བོད་སྐད།", + "vi" to "Tiếng Việt", + "tr" to "Türkçe", + "bg" to "Български", + "ky" to "Кыргызча", + "kk" to "Қазақ Тілі", + "mk" to "Македонски", + "mn" to "Монгол", + "ru" to "Русский", + "sr" to "Српски", + "uk" to "Українська", + "el" to "Ελληνικά", + "hy" to "Հայերեն", + "iw" to "עברית", + "ur" to "اردو", + "ar" to "العربية", + "fa" to "فارسی", + "ne" to "नेपाली", + "mr" to "मराठी", + "hi" to "हिन्दी", + "bn" to "বাংলা", + "pa" to "ਪੰਜਾਬੀ", + "gu" to "ગુજરાતી", + "ta" to "தமிழ்", + "te" to "తెలుగు", + "kn" to "ಕನ್ನಡ", + "ml" to "മലയാളം", + "si" to "සිංහල", + "th" to "ภาษาไทย", + "lo" to "ລາວ", + "my" to "ဗမာ", + "ka" to "ქართული", + "am" to "አማርኛ", + "km" to "ខ្មែរ", + "zh-CN" to "中文 (简体)", + "zh-TW" to "中文 (繁體)", + "zh-HK" to "中文 (香港)", + "ja" to "日本語", + "ko" to "한국어", + ) + +val CountryCodeToName = + mapOf( + "DZ" to "Algeria", + "AR" to "Argentina", + "AU" to "Australia", + "AT" to "Austria", + "AZ" to "Azerbaijan", + "BH" to "Bahrain", + "BD" to "Bangladesh", + "BY" to "Belarus", + "BE" to "Belgium", + "BO" to "Bolivia", + "BA" to "Bosnia and Herzegovina", + "BR" to "Brazil", + "BG" to "Bulgaria", + "KH" to "Cambodia", + "CA" to "Canada", + "CL" to "Chile", + "HK" to "Hong Kong", + "CO" to "Colombia", + "CR" to "Costa Rica", + "HR" to "Croatia", + "CY" to "Cyprus", + "CZ" to "Czech Republic", + "DK" to "Denmark", + "DO" to "Dominican Republic", + "EC" to "Ecuador", + "EG" to "Egypt", + "SV" to "El Salvador", + "EE" to "Estonia", + "FI" to "Finland", + "FR" to "France", + "GE" to "Georgia", + "DE" to "Germany", + "GH" to "Ghana", + "GR" to "Greece", + "GT" to "Guatemala", + "HN" to "Honduras", + "HU" to "Hungary", + "IS" to "Iceland", + "IN" to "India", + "ID" to "Indonesia", + "IQ" to "Iraq", + "IE" to "Ireland", + "IL" to "Israel", + "IT" to "Italy", + "JM" to "Jamaica", + "JP" to "Japan", + "JO" to "Jordan", + "KZ" to "Kazakhstan", + "KE" to "Kenya", + "KR" to "South Korea", + "KW" to "Kuwait", + "LA" to "Lao", + "LV" to "Latvia", + "LB" to "Lebanon", + "LY" to "Libya", + "LI" to "Liechtenstein", + "LT" to "Lithuania", + "LU" to "Luxembourg", + "MK" to "Macedonia", + "MY" to "Malaysia", + "MT" to "Malta", + "MX" to "Mexico", + "ME" to "Montenegro", + "MA" to "Morocco", + "NP" to "Nepal", + "NL" to "Netherlands", + "NZ" to "New Zealand", + "NI" to "Nicaragua", + "NG" to "Nigeria", + "NO" to "Norway", + "OM" to "Oman", + "PK" to "Pakistan", + "PA" to "Panama", + "PG" to "Papua New Guinea", + "PY" to "Paraguay", + "PE" to "Peru", + "PH" to "Philippines", + "PL" to "Poland", + "PT" to "Portugal", + "PR" to "Puerto Rico", + "QA" to "Qatar", + "RO" to "Romania", + "RU" to "Russian Federation", + "SA" to "Saudi Arabia", + "SN" to "Senegal", + "RS" to "Serbia", + "SG" to "Singapore", + "SK" to "Slovakia", + "SI" to "Slovenia", + "ZA" to "South Africa", + "ES" to "Spain", + "LK" to "Sri Lanka", + "SE" to "Sweden", + "CH" to "Switzerland", + "TW" to "Taiwan", + "TZ" to "Tanzania", + "TH" to "Thailand", + "TN" to "Tunisia", + "TR" to "Turkey", + "UG" to "Uganda", + "UA" to "Ukraine", + "AE" to "United Arab Emirates", + "GB" to "United Kingdom", + "US" to "United States", + "UY" to "Uruguay", + "VE" to "Venezuela (Bolivarian Republic)", + "VN" to "Vietnam", + "YE" to "Yemen", + "ZW" to "Zimbabwe", + ) diff --git a/app/src/main/java/com/darkxvenom/airbeats/constants/StatPeriod.kt b/app/src/main/java/com/darkxvenom/airbeats/constants/StatPeriod.kt new file mode 100644 index 00000000..dae81f2e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/constants/StatPeriod.kt @@ -0,0 +1,95 @@ +package com.darkxvenom.airbeats.constants + +import com.darkxvenom.airbeats.ui.screens.OptionStats +import java.time.LocalDateTime +import java.time.ZoneOffset + +enum class StatPeriod { + WEEK_1, + MONTH_1, + MONTH_3, + MONTH_6, + YEAR_1, + ALL, + ; + + fun toTimeMillis(): Long = + when (this) { + WEEK_1 -> + LocalDateTime + .now() + .minusWeeks(1) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + MONTH_1 -> + LocalDateTime + .now() + .minusMonths(1) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + MONTH_3 -> + LocalDateTime + .now() + .minusMonths(3) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + MONTH_6 -> + LocalDateTime + .now() + .minusMonths(6) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + YEAR_1 -> + LocalDateTime + .now() + .minusMonths(12) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + + ALL -> 0 + } +} + +fun statToPeriod( + selection: OptionStats, + test: Int, +): Long = + when (selection) { + OptionStats.WEEKS -> { + LocalDateTime + .now() + .minusWeeks(test.toLong()) + .minusDays(1) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + } + + OptionStats.MONTHS -> { + LocalDateTime + .now() + .withDayOfMonth(1) + .minusMonths(test.toLong()) + .toInstant(ZoneOffset.UTC) + .toEpochMilli() + } + + OptionStats.YEARS -> { + LocalDateTime + .now() + .withDayOfMonth(1) + .withMonth(1) + .minusYears(test.toLong()) + .toInstant( + ZoneOffset.UTC, + ).toEpochMilli() + } + + OptionStats.CONTINUOUS -> { + val index = if (test > StatPeriod.entries.size) 0 else test + StatPeriod.entries[index].toTimeMillis() + } + } diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/Converters.kt b/app/src/main/java/com/darkxvenom/airbeats/db/Converters.kt new file mode 100644 index 00000000..fcf4e46d --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/Converters.kt @@ -0,0 +1,20 @@ +package com.darkxvenom.airbeats.db + +import androidx.room.TypeConverter +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset + +class Converters { + @TypeConverter + fun fromTimestamp(value: Long?): LocalDateTime? = + if (value != null) { + LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.UTC) + } else { + null + } + + @TypeConverter + fun dateToTimestamp(date: LocalDateTime?): Long? = + date?.atZone(ZoneOffset.UTC)?.toInstant()?.toEpochMilli() +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/DatabaseDao.kt b/app/src/main/java/com/darkxvenom/airbeats/db/DatabaseDao.kt new file mode 100644 index 00000000..aa59b38a --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/DatabaseDao.kt @@ -0,0 +1,1353 @@ +package com.darkxvenom.airbeats.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.RawQuery +import androidx.room.Transaction +import androidx.room.Update +import androidx.room.Upsert +import androidx.sqlite.db.SupportSQLiteQuery +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.pages.AlbumPage +import com.darkxvenom.airbeats.innertube.pages.ArtistPage +import com.darkxvenom.airbeats.constants.AlbumSortType +import com.darkxvenom.airbeats.constants.ArtistSongSortType +import com.darkxvenom.airbeats.constants.ArtistSortType +import com.darkxvenom.airbeats.constants.PlaylistSortType +import com.darkxvenom.airbeats.constants.SongSortType +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.db.entities.AlbumArtistMap +import com.darkxvenom.airbeats.db.entities.AlbumEntity +import com.darkxvenom.airbeats.db.entities.AlbumWithSongs +import com.darkxvenom.airbeats.db.entities.Artist +import com.darkxvenom.airbeats.db.entities.ArtistEntity +import com.darkxvenom.airbeats.db.entities.Event +import com.darkxvenom.airbeats.db.entities.EventWithSong +import com.darkxvenom.airbeats.db.entities.FormatEntity +import com.darkxvenom.airbeats.db.entities.LyricsEntity +import com.darkxvenom.airbeats.db.entities.PlayCountEntity +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.db.entities.PlaylistSong +import com.darkxvenom.airbeats.db.entities.PlaylistSongMap +import com.darkxvenom.airbeats.db.entities.RelatedSongMap +import com.darkxvenom.airbeats.db.entities.SearchHistory +import com.darkxvenom.airbeats.db.entities.SetVideoIdEntity +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.db.entities.SongAlbumMap +import com.darkxvenom.airbeats.db.entities.SongArtistMap +import com.darkxvenom.airbeats.db.entities.SongEntity +import com.darkxvenom.airbeats.db.entities.SongWithStats +import com.darkxvenom.airbeats.extensions.reversed +import com.darkxvenom.airbeats.extensions.toSQLiteQuery +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.ui.utils.resize +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import java.text.Collator +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.Locale + +@Dao +interface DatabaseDao { + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY rowId") + fun songsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY inLibrary") + fun songsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY title") + fun songsByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY totalPlayTime") + fun songsByPlayTimeAsc(): Flow> + + fun songs( + sortType: SongSortType, + descending: Boolean, + ) = when (sortType) { + SongSortType.CREATE_DATE -> songsByCreateDateAsc() + SongSortType.NAME -> + songsByNameAsc().map { songs -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + songs.sortedWith(compareBy(collator) { it.song.title }) + } + + SongSortType.ARTIST -> + songsByRowIdAsc().map { songs -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + songs + .sortedWith( + compareBy(collator) { song -> + song.artists.joinToString( + "", + ) { it.name } + }, + ).groupBy { it.album?.title } + .flatMap { (_, songsByAlbum) -> + songsByAlbum.sortedBy { album -> + album.artists.joinToString( + "", + ) { it.name } + } + } + } + + SongSortType.PLAY_TIME -> songsByPlayTimeAsc() + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY rowId") + fun likedSongsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY likedDate") + fun likedSongsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY title") + fun likedSongsByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY totalPlayTime") + fun likedSongsByPlayTimeAsc(): Flow> + + fun likedSongs( + sortType: SongSortType, + descending: Boolean, + ) = when (sortType) { + SongSortType.CREATE_DATE -> likedSongsByCreateDateAsc() + SongSortType.NAME -> + likedSongsByNameAsc().map { songs -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + songs.sortedWith(compareBy(collator) { it.song.title }) + } + + SongSortType.ARTIST -> + likedSongsByRowIdAsc().map { songs -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + songs + .sortedWith( + compareBy(collator) { song -> + song.artists.joinToString( + "", + ) { it.name } + }, + ).groupBy { it.album?.title } + .flatMap { (_, songsByAlbum) -> + songsByAlbum.sortedBy { album -> + album.artists.joinToString( + "", + ) { it.name } + } + } + } + + SongSortType.PLAY_TIME -> likedSongsByPlayTimeAsc() + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT COUNT(1) FROM song WHERE liked") + fun likedSongsCount(): Flow + + @Transaction + @Query("SELECT song.* FROM song JOIN song_album_map ON song.id = song_album_map.songId WHERE song_album_map.albumId = :albumId") + fun albumSongs(albumId: String): Flow> + + @Transaction + @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId ORDER BY position") + fun playlistSongs(playlistId: String): Flow> + + @Transaction + @Query( + "SELECT song.* FROM song_artist_map " + + "JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY inLibrary", + ) + fun artistSongsInAA(artistId: String): Flow> + + @Transaction + @Query( + "SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY inLibrary", + ) + fun artistSongsByCreateDateAsc(artistId: String): Flow> + + @Transaction + @Query( + "SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY title", + ) + fun artistSongsByNameAsc(artistId: String): Flow> + + @Transaction + @Query( + "SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY totalPlayTime", + ) + fun artistSongsByPlayTimeAsc(artistId: String): Flow> + + fun artistSongs( + artistId: String, + sortType: ArtistSongSortType, + descending: Boolean, + ) = when (sortType) { + ArtistSongSortType.CREATE_DATE -> artistSongsByCreateDateAsc(artistId) + ArtistSongSortType.NAME -> + artistSongsByNameAsc(artistId).map { artistSongs -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + artistSongs.sortedWith(compareBy(collator) { it.song.title }) + } + + ArtistSongSortType.PLAY_TIME -> artistSongsByPlayTimeAsc(artistId) + }.map { it.reversed(descending) } + + @Transaction + @Query( + "SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL LIMIT :previewSize", + ) + fun artistSongsPreview( + artistId: String, + previewSize: Int = 3, + ): Flow> + + @Transaction + @Query( + """ + SELECT song.* + FROM (SELECT *, COUNT(1) AS referredCount + FROM related_song_map + GROUP BY relatedSongId) map + JOIN song ON song.id = map.relatedSongId + WHERE songId IN (SELECT songId + FROM (SELECT songId + FROM event + ORDER BY ROWID DESC + LIMIT 5) + UNION + SELECT songId + FROM (SELECT songId + FROM event + WHERE timestamp > :now - 86400000 * 7 + GROUP BY songId + ORDER BY SUM(playTime) DESC + LIMIT 5) + UNION + SELECT id + FROM (SELECT id + FROM song + ORDER BY totalPlayTime DESC + LIMIT 10)) + ORDER BY referredCount DESC + LIMIT 100 + """, + ) + fun quickPicks(now: Long = System.currentTimeMillis()): Flow> + + @Transaction + @Query( + """ + SELECT + song.* + FROM + event + JOIN + song ON event.songId = song.id + WHERE + event.timestamp > (:now - 86400000 * 7 * 2) + GROUP BY + song.albumId + HAVING + song.albumId IS NOT NULL + ORDER BY + sum(event.playTime) DESC + LIMIT :limit + OFFSET :offset + + """, + ) + fun getRecommendationAlbum( + now: Long = System.currentTimeMillis(), + limit: Int = 5, + offset: Int = 0, + ): Flow> + + @Transaction + @Query( + """ + SELECT song.id, song.title, song.thumbnailUrl, + (SELECT COUNT(1) + FROM event + WHERE songId = song.id + AND timestamp > :fromTimeStamp AND timestamp <= :toTimeStamp) AS songCountListened, + (SELECT SUM(event.playTime) + FROM event + WHERE songId = song.id + AND timestamp > :fromTimeStamp AND timestamp <= :toTimeStamp) AS timeListened + FROM song + JOIN (SELECT songId + FROM event + WHERE timestamp > :fromTimeStamp + AND timestamp <= :toTimeStamp + GROUP BY songId + ORDER BY SUM(playTime) DESC + LIMIT :limit) + ON song.id = songId + LIMIT :limit + OFFSET :offset + """, + ) + fun mostPlayedSongsStats( + fromTimeStamp: Long, + limit: Int = 6, + offset: Int = 0, + toTimeStamp: Long? = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli(), + ): Flow> + + @Transaction + @Query( + """ + SELECT song.*, + (SELECT COUNT(1) + FROM event + WHERE songId = song.id + AND timestamp > :fromTimeStamp AND timestamp <= :toTimeStamp) AS songCountListened, + (SELECT SUM(event.playTime) + FROM event + WHERE songId = song.id + AND timestamp > :fromTimeStamp AND timestamp <= :toTimeStamp) AS timeListened + FROM song + JOIN (SELECT songId + FROM event + WHERE timestamp > :fromTimeStamp + AND timestamp <= :toTimeStamp + GROUP BY songId + ORDER BY SUM(playTime) DESC + LIMIT :limit) + ON song.id = songId + LIMIT :limit + OFFSET :offset + """, + ) + fun mostPlayedSongs( + fromTimeStamp: Long, + limit: Int = 6, + offset: Int = 0, + toTimeStamp: Long? = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli(), + ): Flow> + + @Transaction + @Query( + """ + SELECT artist.*, + (SELECT COUNT(1) + FROM song_artist_map + JOIN event ON song_artist_map.songId = event.songId + WHERE artistId = artist.id + AND timestamp > :fromTimeStamp AND timestamp <= :toTimeStamp) AS songCount, + (SELECT SUM(event.playTime) + FROM song_artist_map + JOIN event ON song_artist_map.songId = event.songId + WHERE artistId = artist.id + AND timestamp > :fromTimeStamp AND timestamp <= :toTimeStamp) AS timeListened + FROM artist + JOIN(SELECT artistId, SUM(songTotalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN (SELECT songId, SUM(playTime) AS songTotalPlayTime + FROM event + WHERE timestamp > :fromTimeStamp + AND timestamp <= :toTimeStamp + GROUP BY songId) AS e + ON song_artist_map.songId = e.songId + GROUP BY artistId + ORDER BY totalPlayTime DESC + LIMIT :limit + OFFSET :offset) + ON artist.id = artistId + """, + ) + fun mostPlayedArtists( + fromTimeStamp: Long, + limit: Int = 6, + offset: Int = 0, + toTimeStamp: Long? = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli(), + ): Flow> + + @Transaction + @Query( + """ + SELECT album.*, + COUNT(DISTINCT song_album_map.songId) as downloadCount, + (SELECT COUNT(1) + FROM song_album_map + JOIN event e ON song_album_map.songId = e.songId + WHERE albumId = album.id + AND e.timestamp > :fromTimeStamp + AND e.timestamp <= :toTimeStamp) AS songCountListened, + (SELECT SUM(e.playTime) + FROM song_album_map + JOIN event e ON song_album_map.songId = e.songId + WHERE albumId = album.id + AND e.timestamp > :fromTimeStamp + AND e.timestamp <= :toTimeStamp) AS timeListened + FROM album + JOIN song_album_map ON album.id = song_album_map.albumId + WHERE album.id IN ( + SELECT sam.albumId + FROM event + JOIN song_album_map sam ON event.songId = sam.songId + WHERE event.timestamp > :fromTimeStamp + AND event.timestamp <= :toTimeStamp + GROUP BY sam.albumId + HAVING sam.albumId IS NOT NULL + ) + GROUP BY album.id + ORDER BY timeListened DESC + LIMIT :limit OFFSET :offset + """ + ) + fun mostPlayedAlbums( + fromTimeStamp: Long, + limit: Int = 6, + offset: Int = 0, + toTimeStamp: Long? = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli(), + ): Flow> + + @Query("SELECT sum(count) from playCount WHERE song = :songId") + fun getLifetimePlayCount(songId: String?): Flow + + @Query("SELECT sum(count) from playCount WHERE song = :songId AND year = :year") + fun getPlayCountByYear(songId: String?, year: Int): Flow + + @Query("SELECT count from playCount WHERE song = :songId AND year = :year AND month = :month") + fun getPlayCountByMonth(songId: String?, year: Int, month: Int): Flow + + @Transaction + @Query( + """ + SELECT song.* + FROM (SELECT n.songId AS eid, + SUM(playTime) AS oldPlayTime, + newPlayTime + FROM event + JOIN + (SELECT songId, SUM(playTime) AS newPlayTime + FROM event + WHERE timestamp > (:now - 86400000 * 30 * 1) + GROUP BY songId + ORDER BY newPlayTime) as n + ON event.songId = n.songId + WHERE timestamp < (:now - 86400000 * 30 * 1) + GROUP BY n.songId + ORDER BY oldPlayTime) AS t + JOIN song on song.id = t.eid + WHERE 0.2 * t.oldPlayTime > t.newPlayTime + LIMIT 100 + """ + ) + fun forgottenFavorites(now: Long = System.currentTimeMillis()): Flow> + + @Transaction + @Query( + """ + SELECT song.* + FROM event + JOIN + song ON event.songId = song.id + WHERE event.timestamp > (:now - 86400000 * 7 * 2) + GROUP BY song.albumId + HAVING song.albumId IS NOT NULL + ORDER BY sum(event.playTime) DESC + LIMIT :limit + OFFSET :offset + """, + ) + fun recommendedAlbum( + now: Long = System.currentTimeMillis(), + limit: Int = 5, + offset: Int = 0, + ): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE id = :songId") + fun song(songId: String?): Flow + + @Transaction + @Query("SELECT * FROM Song WHERE id = :songId LIMIT 1") + fun getSongById(songId: String): Song? + + @Transaction + @Query("SELECT * FROM song_artist_map WHERE songId = :songId") + fun songArtistMap(songId: String): List + + @Transaction + @Query("SELECT * FROM song") + fun allSongs(): Flow> + + @Transaction + @Query( + """ + SELECT DISTINCT artist.*, + (SELECT COUNT(1) + FROM song_artist_map + JOIN event ON song_artist_map.songId = event.songId + WHERE artistId = artist.id) AS songCount + FROM artist + LEFT JOIN(SELECT artistId, SUM(songTotalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN (SELECT songId, SUM(playTime) AS songTotalPlayTime + FROM event + GROUP BY songId) AS e + ON song_artist_map.songId = e.songId + GROUP BY artistId + ORDER BY totalPlayTime DESC) AS artistTotalPlayTime + ON artist.id = artistId + OR artist.bookmarkedAt IS NOT NULL + ORDER BY + CASE + WHEN artistTotalPlayTime.artistId IS NULL THEN 1 + ELSE 0 + END, + artistTotalPlayTime.totalPlayTime DESC + """, + ) + fun allArtistsByPlayTime(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSetVideoId(setVideoIdEntity: SetVideoIdEntity) + + @Query("SELECT * FROM set_video_id WHERE videoId = :videoId") + suspend fun getSetVideoId(videoId: String): SetVideoIdEntity? + + @Transaction + @Query("SELECT * FROM format WHERE id = :id") + fun format(id: String?): Flow + + suspend fun getLyrics(id: String?): LyricsEntity? { + return lyrics(id).first() + } + + @Transaction + @Query("SELECT * FROM lyrics WHERE id = :id") + fun lyrics(id: String?): Flow + + @Transaction + @Query( + """ + SELECT *, + (SELECT COUNT(1) + FROM song_artist_map + JOIN song ON song_artist_map.songId = song.id + WHERE artistId = artist.id + AND song.inLibrary IS NOT NULL) AS songCount + FROM artist + WHERE (songCount > 0 OR bookmarkedAt IS NOT NULL) + ORDER BY rowId DESC + """ + ) + fun artistsInAA(): Flow> + + @Transaction + @Query( + "SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY rowId", + ) + fun artistsByCreateDateAsc(): Flow> + + @Transaction + @Query( + "SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY name", + ) + fun artistsByNameAsc(): Flow> + + @Transaction + @Query( + "SELECT * FROM artist WHERE songCount > 0 ORDER BY songCount" + ) + fun artistsBySongCountAsc(): Flow> + + @Transaction + @Query( + """ + SELECT artist.*, + (SELECT COUNT(1) + FROM song_artist_map + JOIN song ON song_artist_map.songId = song.id + WHERE artistId = artist.id + AND song.inLibrary IS NOT NULL) AS songCount + FROM artist + JOIN(SELECT artistId, SUM(totalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN song + ON song_artist_map.songId = song.id + GROUP BY artistId + ORDER BY totalPlayTime) + ON artist.id = artistId + WHERE songCount > 0 + """, + ) + fun artistsByPlayTimeAsc(): Flow> + + @Transaction + @Query( + "SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt", + ) + fun artistsBookmarkedByCreateDateAsc(): Flow> + + @Transaction + @Query( + "SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE bookmarkedAt IS NOT NULL ORDER BY name", + ) + fun artistsBookmarkedByNameAsc(): Flow> + + @Transaction + @Query( + "SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE bookmarkedAt IS NOT NULL ORDER BY songCount", + ) + fun artistsBookmarkedBySongCountAsc(): Flow> + + @Transaction + @Query( + """ + SELECT artist.*, + (SELECT COUNT(1) + FROM song_artist_map + JOIN song ON song_artist_map.songId = song.id + WHERE artistId = artist.id + AND song.inLibrary IS NOT NULL) AS songCount + FROM artist + JOIN(SELECT artistId, SUM(totalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN song + ON song_artist_map.songId = song.id + GROUP BY artistId + ORDER BY totalPlayTime) + ON artist.id = artistId + WHERE bookmarkedAt IS NOT NULL + """, + ) + fun artistsBookmarkedByPlayTimeAsc(): Flow> + + @Query("UPDATE artist SET songCount = :count WHERE id = :artistId") + suspend fun updateArtistSongCount(artistId: String, count: Int) + + @Transaction + suspend fun updateArtistSongsCount(artistId: String) { + val count = getSongCountForArtist(artistId) + updateArtistSongCount(artistId, count) + } + + @Query("SELECT COUNT(*) FROM song_artist_map WHERE artistId = :artistId") + suspend fun getSongCountForArtist(artistId: String): Int + + fun artists( + sortType: ArtistSortType, + descending: Boolean, + ) = when (sortType) { + ArtistSortType.CREATE_DATE -> artistsByCreateDateAsc() + ArtistSortType.NAME -> + artistsByNameAsc().map { artist -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + artist.sortedWith(compareBy(collator) { it.artist.name }) + } + + ArtistSortType.SONG_COUNT -> artistsBySongCountAsc() + ArtistSortType.PLAY_TIME -> artistsByPlayTimeAsc() + }.map { artists -> + artists + .filter { it.artist.isYouTubeArtist } + .reversed(descending) + } + + fun artistsBookmarked( + sortType: ArtistSortType, + descending: Boolean, + ) = when (sortType) { + ArtistSortType.CREATE_DATE -> artistsBookmarkedByCreateDateAsc() + ArtistSortType.NAME -> + artistsBookmarkedByNameAsc().map { artist -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + artist.sortedWith(compareBy(collator) { it.artist.name }) + } + + ArtistSortType.SONG_COUNT -> artistsBookmarkedBySongCountAsc() + ArtistSortType.PLAY_TIME -> artistsBookmarkedByPlayTimeAsc() + }.map { artists -> + artists + .filter { it.artist.isYouTubeArtist } + .reversed(descending) + } + + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE id = :id") + fun artist(id: String): Flow + + @Transaction + @Query( + """ + SELECT *, + (SELECT COUNT(1) + FROM song + WHERE song.albumId = album.id + AND song.inLibrary IS NOT NULL) AS songCount + FROM album + WHERE (songCount > 0 OR album.bookmarkedAt IS NOT NULL) + ORDER BY rowId DESC + """ + ) + fun albumsInAA(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY rowId") + fun albumsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY title") + fun albumsByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY year") + fun albumsByYearAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY songCount") + fun albumsBySongCountAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY duration") + fun albumsByLengthAsc(): Flow> + + @Transaction + @Query( + """ + SELECT album.* + FROM album + JOIN song + ON song.albumId = album.id + WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) + GROUP BY album.id + ORDER BY SUM(song.totalPlayTime) + """, + ) + fun albumsByPlayTimeAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY rowId") + fun albumsLikedByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY title") + fun albumsLikedByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY year") + fun albumsLikedByYearAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY songCount") + fun albumsLikedBySongCountAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY duration") + fun albumsLikedByLengthAsc(): Flow> + + @Transaction + @Query( + """ + SELECT album.* + FROM album + JOIN song + ON song.albumId = album.id + WHERE bookmarkedAt IS NOT NULL + GROUP BY album.id + ORDER BY SUM(song.totalPlayTime) + """ + ) + fun albumsLikedByPlayTimeAsc(): Flow> + + fun albums( + sortType: AlbumSortType, + descending: Boolean, + ) = when (sortType) { + AlbumSortType.CREATE_DATE -> albumsByCreateDateAsc() + AlbumSortType.NAME -> + albumsByNameAsc().map { albums -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + albums.sortedWith(compareBy(collator) { it.album.title }) + } + + AlbumSortType.ARTIST -> + albumsByCreateDateAsc().map { albums -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + albums.sortedWith(compareBy(collator) { album -> album.artists.joinToString("") { it.name } }) + } + + AlbumSortType.YEAR -> albumsByYearAsc() + AlbumSortType.SONG_COUNT -> albumsBySongCountAsc() + AlbumSortType.LENGTH -> albumsByLengthAsc() + AlbumSortType.PLAY_TIME -> albumsByPlayTimeAsc() + }.map { it.reversed(descending) } + + fun albumsLiked( + sortType: AlbumSortType, + descending: Boolean, + ) = when (sortType) { + AlbumSortType.CREATE_DATE -> albumsLikedByCreateDateAsc() + AlbumSortType.NAME -> + albumsLikedByNameAsc().map { albums -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + albums.sortedWith(compareBy(collator) { it.album.title }) + } + + AlbumSortType.ARTIST -> + albumsLikedByCreateDateAsc().map { albums -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + albums.sortedWith(compareBy(collator) { album -> album.artists.joinToString("") { it.name } }) + } + + AlbumSortType.YEAR -> albumsLikedByYearAsc() + AlbumSortType.SONG_COUNT -> albumsLikedBySongCountAsc() + AlbumSortType.LENGTH -> albumsLikedByLengthAsc() + AlbumSortType.PLAY_TIME -> albumsLikedByPlayTimeAsc() + }.map { it.reversed(descending) } + + @Transaction + @Query("SELECT * FROM album WHERE id = :id") + fun album(id: String): Flow + + @Transaction + @Query("SELECT * FROM album WHERE id = :albumId") + fun albumWithSongs(albumId: String): Flow + + @Transaction + @Query("SELECT * FROM album_artist_map WHERE albumId = :albumId") + fun albumArtistMaps(albumId: String): List + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE bookmarkedAt IS NOT NULL ORDER BY rowId") + fun playlistsByCreateDateAsc(): Flow> + + @Transaction + @Query( + "SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE bookmarkedAt IS NOT NULL ORDER BY lastUpdateTime", + ) + fun playlistsByUpdatedDateAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE bookmarkedAt IS NOT NULL ORDER BY name") + fun playlistsByNameAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE bookmarkedAt IS NOT NULL ORDER BY songCount") + fun playlistsBySongCountAsc(): Flow> + + fun playlists( + sortType: PlaylistSortType, + descending: Boolean, + ) = when (sortType) { + PlaylistSortType.CREATE_DATE -> playlistsByCreateDateAsc() + PlaylistSortType.NAME -> + playlistsByNameAsc().map { playlists -> + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + playlists.sortedWith(compareBy(collator) { it.playlist.name }) + } + + PlaylistSortType.SONG_COUNT -> playlistsBySongCountAsc() + PlaylistSortType.LAST_UPDATED -> playlistsByUpdatedDateAsc() + }.map { it.reversed(descending) } + + @Transaction + @Query( + "SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE id = :playlistId", + ) + fun playlist(playlistId: String): Flow + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE isEditable AND bookmarkedAt IS NOT NULL ORDER BY rowId") + fun editablePlaylistsByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE browseId = :browseId") + fun playlistByBrowseId(browseId: String): Flow + + @Transaction + @Query("SELECT COUNT(*) from playlist_song_map WHERE playlistId = :playlistId AND songId = :songId LIMIT 1") + fun checkInPlaylist( + playlistId: String, + songId: String, + ): Int + + @Query("SELECT songId from playlist_song_map WHERE playlistId = :playlistId AND songId IN (:songIds)") + fun playlistDuplicates( + playlistId: String, + songIds: List, + ): List + + @Transaction + fun addSongToPlaylist(playlist: Playlist, songIds: List) { + var position = playlist.songCount + songIds.forEach { id -> + insert( + PlaylistSongMap( + songId = id, + playlistId = playlist.id, + position = position++ + ) + ) + } + } + + @Transaction + @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND inLibrary IS NOT NULL LIMIT :previewSize") + fun searchSongs( + query: String, + previewSize: Int = Int.MAX_VALUE, + ): Flow> + + @Transaction + @Query( + "SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' AND songCount > 0 LIMIT :previewSize", + ) + fun searchArtists( + query: String, + previewSize: Int = Int.MAX_VALUE, + ): Flow> + + @Transaction + @Query( + "SELECT * FROM album WHERE title LIKE '%' || :query || '%' AND EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) LIMIT :previewSize", + ) + fun searchAlbums( + query: String, + previewSize: Int = Int.MAX_VALUE, + ): Flow> + + @Transaction + @Query( + "SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize", + ) + fun searchPlaylists( + query: String, + previewSize: Int = Int.MAX_VALUE, + ): Flow> + + @Transaction + @Query("SELECT * FROM event ORDER BY rowId DESC") + fun events(): Flow> + + @Transaction + @Query("SELECT * FROM event ORDER BY rowId ASC LIMIT 1") + fun firstEvent(): Flow + + @Transaction + @Query("DELETE FROM event") + fun clearListenHistory() + + @Transaction + @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC") + fun searchHistory(query: String = ""): Flow> + + @Transaction + @Query("DELETE FROM search_history") + fun clearSearchHistory() + + @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId") + fun incrementTotalPlayTime(songId: String, playTime: Long) + + @Query("UPDATE playCount SET count = count + 1 WHERE song = :songId AND year = :year AND month = :month") + fun incrementPlayCount(songId: String, year: Int, month: Int) + + /** Increment by one the play count with today's year and month. */ + fun incrementPlayCount(songId: String) { + val time = LocalDateTime.now().atOffset(ZoneOffset.UTC) + var oldCount: Int + runBlocking { + oldCount = getPlayCountByMonth(songId, time.year, time.monthValue).first() + } + + // add new + if (oldCount <= 0) { + insert(PlayCountEntity(songId, time.year, time.monthValue, 0)) + } + incrementPlayCount(songId, time.year, time.monthValue) + } + + @Transaction + @Query("UPDATE song SET inLibrary = :inLibrary WHERE id = :songId") + fun inLibrary( + songId: String, + inLibrary: LocalDateTime?, + ) + + @Transaction + @Query("SELECT COUNT(1) FROM related_song_map WHERE songId = :songId LIMIT 1") + fun hasRelatedSongs(songId: String): Boolean + + @Transaction + @Query( + "SELECT song.* FROM (SELECT * from related_song_map GROUP BY relatedSongId) map JOIN song ON song.id = map.relatedSongId where songId = :songId", + ) + fun getRelatedSongs(songId: String): Flow> + + @Transaction + @Query( + """ + SELECT song.* + FROM (SELECT * + FROM related_song_map + GROUP BY relatedSongId) map + JOIN + song + ON song.id = map.relatedSongId + WHERE songId = :songId + """ + ) + fun relatedSongs(songId: String): List + + @Transaction + @Query( + """ + UPDATE playlist_song_map 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: String, + fromPosition: Int, + toPosition: Int, + ) + + @Transaction + @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId") + fun clearPlaylist(playlistId: String) + + @Transaction + @Query("SELECT * FROM artist WHERE name = :name") + fun artistByName(name: String): ArtistEntity? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(song: SongEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(artist: ArtistEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(album: AlbumEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(playlist: PlaylistEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: SongArtistMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: SongAlbumMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: AlbumArtistMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: PlaylistSongMap) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(searchHistory: SearchHistory) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(event: Event) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: RelatedSongMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(playCountEntity: PlayCountEntity): Long + + @Transaction + fun insert( + mediaMetadata: MediaMetadata, + block: (SongEntity) -> SongEntity = { it }, + ) { + if (insert(mediaMetadata.toSongEntity().let(block)) == -1L) return + mediaMetadata.artists.forEachIndexed { index, artist -> + val artistId = + artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId() + insert( + ArtistEntity( + id = artistId, + name = artist.name, + ), + ) + insert( + SongArtistMap( + songId = mediaMetadata.id, + artistId = artistId, + position = index, + ), + ) + } + } + + @Transaction + fun insert(albumPage: AlbumPage) { + if (insert( + AlbumEntity( + id = albumPage.album.browseId, + playlistId = albumPage.album.playlistId, + title = albumPage.album.title, + year = albumPage.album.year, + thumbnailUrl = albumPage.album.thumbnail, + songCount = albumPage.songs.size, + duration = albumPage.songs.sumOf { it.duration ?: 0 }, + ), + ) == -1L + ) { + return + } + albumPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .onEach { + val existingSong = getSongById(it.id) + if (existingSong != null) { + update(existingSong, it) + } + }.mapIndexed { index, song -> + SongAlbumMap( + songId = song.id, + albumId = albumPage.album.browseId, + index = index, + ) + }.forEach(::upsert) + albumPage.album.artists + ?.map { artist -> + ArtistEntity( + id = artist.id ?: artistByName(artist.name)?.id + ?: ArtistEntity.generateArtistId(), + name = artist.name, + ) + }?.onEach(::insert) + ?.mapIndexed { index, artist -> + AlbumArtistMap( + albumId = albumPage.album.browseId, + artistId = artist.id, + order = index, + ) + }?.forEach(::insert) + } + + @Transaction + fun update( + song: Song, + mediaMetadata: MediaMetadata, + ) { + update( + song.song.copy( + title = mediaMetadata.title, + duration = mediaMetadata.duration, + thumbnailUrl = mediaMetadata.thumbnailUrl, + albumId = mediaMetadata.album?.id, + albumName = mediaMetadata.album?.title, + ), + ) + songArtistMap(song.id).forEach(::delete) + mediaMetadata.artists.forEachIndexed { index, artist -> + val artistId = + artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId() + insert( + ArtistEntity( + id = artistId, + name = artist.name, + ), + ) + insert( + SongArtistMap( + songId = song.id, + artistId = artistId, + position = index, + ), + ) + } + } + + @Update + fun update(song: SongEntity) + + @Update + fun update(artist: ArtistEntity) + + @Update + fun update(album: AlbumEntity) + + @Update + fun update(playlist: PlaylistEntity) + + @Update + fun update(map: PlaylistSongMap) + + fun update( + artist: ArtistEntity, + artistPage: ArtistPage, + ) { + update( + artist.copy( + name = artistPage.artist.title, + thumbnailUrl = artistPage.artist.thumbnail.resize(544, 544), + lastUpdateTime = LocalDateTime.now(), + ), + ) + } + + @Transaction + fun update( + album: AlbumEntity, + albumPage: AlbumPage, + artists: List? = emptyList(), + ) { + update( + album.copy( + id = albumPage.album.browseId, + playlistId = albumPage.album.playlistId, + title = albumPage.album.title, + year = albumPage.album.year, + thumbnailUrl = albumPage.album.thumbnail, + songCount = albumPage.songs.size, + duration = albumPage.songs.sumOf { it.duration ?: 0 }, + ), + ) + if (artists?.size != albumPage.album.artists?.size) { + artists?.forEach(::delete) + } + albumPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .onEach { + val existingSong = getSongById(it.id) + if (existingSong != null) { + update(existingSong, it) + } + }.mapIndexed { index, song -> + SongAlbumMap( + songId = song.id, + albumId = albumPage.album.browseId, + index = index, + ) + }.forEach(::upsert) + + albumPage.album.artists?.let { artists -> + // Recreate album artists + albumArtistMaps(album.id).forEach(::delete) + artists + .map { artist -> + ArtistEntity( + id = artist.id ?: artistByName(artist.name)?.id + ?: ArtistEntity.generateArtistId(), + name = artist.name, + ) + }.onEach(::insert) + .mapIndexed { index, artist -> + AlbumArtistMap( + albumId = albumPage.album.browseId, + artistId = artist.id, + order = index, + ) + }.forEach(::insert) + } + } + + @Update + fun update(playlistEntity: PlaylistEntity, playlistItem: PlaylistItem) { + update( + playlistEntity.copy( + name = playlistItem.title, + browseId = playlistItem.id, + isEditable = playlistItem.isEditable, + remoteSongCount = playlistItem.songCountText?.let { Regex("""\d+""").find(it)?.value?.toIntOrNull() }, + playEndpointParams = playlistItem.playEndpoint?.params, + shuffleEndpointParams = playlistItem.shuffleEndpoint?.params, + radioEndpointParams = playlistItem.radioEndpoint?.params + ) + ) + } + + @Upsert + fun upsert(map: SongAlbumMap) + + @Upsert + fun upsert(lyrics: LyricsEntity) + + @Upsert + fun upsert(format: FormatEntity) + + @Delete + fun delete(song: SongEntity) + + @Delete + fun delete(songArtistMap: SongArtistMap) + + @Delete + fun delete(artist: ArtistEntity) + + @Delete + fun delete(album: AlbumEntity) + + @Delete + fun delete(albumArtistMap: AlbumArtistMap) + + @Delete + fun delete(playlist: PlaylistEntity) + + @Delete + fun delete(playlistSongMap: PlaylistSongMap) + + @Query("DELETE FROM playlist WHERE browseId = :browseId") + fun deletePlaylistById(browseId: String) + + @Delete + fun delete(lyrics: LyricsEntity) + + @Delete + fun delete(searchHistory: SearchHistory) + + @Delete + fun delete(event: Event) + + @Transaction + @Query("SELECT * FROM playlist_song_map WHERE songId = :songId") + fun playlistSongMaps(songId: String): List + + @Transaction + @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position") + fun playlistSongMaps( + playlistId: String, + from: Int, + ): List + + @RawQuery + fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int + + fun checkpoint() { + raw("PRAGMA wal_checkpoint(FULL)".toSQLiteQuery()) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/MusicDatabase.kt b/app/src/main/java/com/darkxvenom/airbeats/db/MusicDatabase.kt new file mode 100644 index 00000000..da75feff --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/MusicDatabase.kt @@ -0,0 +1,465 @@ +package com.darkxvenom.airbeats.db + +import android.annotation.SuppressLint +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.core.content.contentValuesOf +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.DeleteColumn +import androidx.room.DeleteTable +import androidx.room.RenameColumn +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import com.darkxvenom.airbeats.db.entities.AlbumArtistMap +import com.darkxvenom.airbeats.db.entities.AlbumEntity +import com.darkxvenom.airbeats.db.entities.ArtistEntity +import com.darkxvenom.airbeats.db.entities.Event +import com.darkxvenom.airbeats.db.entities.FormatEntity +import com.darkxvenom.airbeats.db.entities.LyricsEntity +import com.darkxvenom.airbeats.db.entities.PlayCountEntity +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.db.entities.PlaylistSongMap +import com.darkxvenom.airbeats.db.entities.PlaylistSongMapPreview +import com.darkxvenom.airbeats.db.entities.RelatedSongMap +import com.darkxvenom.airbeats.db.entities.SearchHistory +import com.darkxvenom.airbeats.db.entities.SetVideoIdEntity +import com.darkxvenom.airbeats.db.entities.SongAlbumMap +import com.darkxvenom.airbeats.db.entities.SongArtistMap +import com.darkxvenom.airbeats.db.entities.SongEntity +import com.darkxvenom.airbeats.db.entities.SortedSongAlbumMap +import com.darkxvenom.airbeats.db.entities.SortedSongArtistMap +import com.darkxvenom.airbeats.extensions.toSQLiteQuery +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.Date + +class MusicDatabase( + private val delegate: InternalDatabase, +) : DatabaseDao by delegate.dao { + val openHelper: SupportSQLiteOpenHelper + get() = delegate.openHelper + + fun query(block: MusicDatabase.() -> Unit) = + with(delegate) { + queryExecutor.execute { + block(this@MusicDatabase) + } + } + + fun transaction(block: MusicDatabase.() -> Unit) = + with(delegate) { + transactionExecutor.execute { + runInTransaction { + block(this@MusicDatabase) + } + } + } + + fun close() = delegate.close() +} + +@Database( + entities = [ + SongEntity::class, + ArtistEntity::class, + AlbumEntity::class, + PlaylistEntity::class, + SongArtistMap::class, + SongAlbumMap::class, + AlbumArtistMap::class, + PlaylistSongMap::class, + SearchHistory::class, + FormatEntity::class, + LyricsEntity::class, + Event::class, + RelatedSongMap::class, + SetVideoIdEntity::class, + PlayCountEntity::class + ], + views = [ + SortedSongArtistMap::class, + SortedSongAlbumMap::class, + PlaylistSongMapPreview::class, + ], + version = 19, + exportSchema = true, + autoMigrations = [ + AutoMigration(from = 2, to = 3), + AutoMigration(from = 3, to = 4), + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6, spec = Migration5To6::class), + AutoMigration(from = 6, to = 7, spec = Migration6To7::class), + AutoMigration(from = 7, to = 8, spec = Migration7To8::class), + AutoMigration(from = 8, to = 9), + AutoMigration(from = 9, to = 10, spec = Migration9To10::class), + AutoMigration(from = 10, to = 11, spec = Migration10To11::class), + AutoMigration(from = 11, to = 12, spec = Migration11To12::class), + AutoMigration(from = 12, to = 13, spec = Migration12To13::class), + AutoMigration(from = 13, to = 14, spec = Migration13To14::class), + AutoMigration(from = 14, to = 15), + AutoMigration(from = 15, to = 16), + AutoMigration(from = 16, to = 17, spec = Migration16To17::class), + AutoMigration(from = 17, to = 18), + AutoMigration(from = 18, to = 19), + ], +) +@TypeConverters(Converters::class) +abstract class InternalDatabase : RoomDatabase() { + abstract val dao: DatabaseDao + + companion object { + const val DB_NAME = "song.db" + + fun newInstance(context: Context): MusicDatabase = + MusicDatabase( + delegate = + Room + .databaseBuilder(context, InternalDatabase::class.java, DB_NAME) + .addMigrations(MIGRATION_1_2) + .build(), + ) + } +} + +val MIGRATION_1_2 = + object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + data class OldSongEntity( + val id: String, + val title: String, + val duration: Int = -1, // in seconds + val thumbnailUrl: String? = null, + val albumId: String? = null, + val albumName: String? = null, + val liked: Boolean = false, + val totalPlayTime: Long = 0, // in milliseconds + val downloadState: Int = 0, + val createDate: LocalDateTime = LocalDateTime.now(), + val modifyDate: LocalDateTime = LocalDateTime.now(), + ) + + val converters = Converters() + val artistMap = mutableMapOf() + val artists = mutableListOf() + database.query("SELECT * FROM artist".toSQLiteQuery()).use { cursor -> + while (cursor.moveToNext()) { + val oldId = cursor.getInt(0) + val newId = ArtistEntity.generateArtistId() + artistMap[oldId] = newId + artists.add( + ArtistEntity( + id = newId, + name = cursor.getString(1), + ), + ) + } + } + + val playlistMap = mutableMapOf() + val playlists = mutableListOf() + database.query("SELECT * FROM playlist".toSQLiteQuery()).use { cursor -> + while (cursor.moveToNext()) { + val oldId = cursor.getInt(0) + val newId = PlaylistEntity.generatePlaylistId() + playlistMap[oldId] = newId + playlists.add( + PlaylistEntity( + id = newId, + name = cursor.getString(1), + ), + ) + } + } + val playlistSongMaps = mutableListOf() + database.query("SELECT * FROM playlist_song".toSQLiteQuery()).use { cursor -> + while (cursor.moveToNext()) { + playlistSongMaps.add( + PlaylistSongMap( + playlistId = playlistMap[cursor.getInt(1)]!!, + songId = cursor.getString(2), + position = cursor.getInt(3), + ), + ) + } + } + // ensure we have continuous playlist song position + playlistSongMaps.sortBy { it.position } + val playlistSongCount = mutableMapOf() + playlistSongMaps.map { map -> + if (map.playlistId !in playlistSongCount) playlistSongCount[map.playlistId] = 0 + map.copy(position = playlistSongCount[map.playlistId]!!).also { + playlistSongCount[map.playlistId] = playlistSongCount[map.playlistId]!! + 1 + } + } + val songs = mutableListOf() + val songArtistMaps = mutableListOf() + database.query("SELECT * FROM song".toSQLiteQuery()).use { cursor -> + while (cursor.moveToNext()) { + val songId = cursor.getString(0) + songs.add( + OldSongEntity( + id = songId, + title = cursor.getString(1), + duration = cursor.getInt(3), + liked = cursor.getInt(4) == 1, + createDate = Instant.ofEpochMilli(Date(cursor.getLong(8)).time) + .atZone(ZoneOffset.UTC).toLocalDateTime(), + modifyDate = Instant.ofEpochMilli(Date(cursor.getLong(9)).time) + .atZone(ZoneOffset.UTC).toLocalDateTime(), + ), + ) + songArtistMaps.add( + SongArtistMap( + songId = songId, + artistId = artistMap[cursor.getInt(2)]!!, + position = 0, + ), + ) + } + } + database.execSQL("DROP TABLE IF EXISTS song") + database.execSQL("DROP TABLE IF EXISTS artist") + database.execSQL("DROP TABLE IF EXISTS playlist") + database.execSQL("DROP TABLE IF EXISTS playlist_song") + database.execSQL( + "CREATE TABLE IF NOT EXISTS `song` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `isTrash` INTEGER NOT NULL, `download_state` INTEGER NOT NULL, `create_date` INTEGER NOT NULL, `modify_date` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + database.execSQL( + "CREATE TABLE IF NOT EXISTS `artist` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + database.execSQL( + "CREATE TABLE IF NOT EXISTS `album` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + database.execSQL( + "CREATE TABLE IF NOT EXISTS `playlist` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT, `authorId` TEXT, `year` INTEGER, `thumbnailUrl` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + database.execSQL( + "CREATE TABLE IF NOT EXISTS `song_artist_map` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER 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 )", + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `song_artist_map` (`songId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `song_artist_map` (`artistId`)") + database.execSQL( + "CREATE TABLE IF NOT EXISTS `song_album_map` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` 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 )", + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `song_album_map` (`songId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `song_album_map` (`albumId`)") + database.execSQL( + "CREATE TABLE IF NOT EXISTS `album_artist_map` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `album_artist_map` (`albumId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `album_artist_map` (`artistId`)") + database.execSQL( + "CREATE TABLE IF NOT EXISTS `playlist_song_map` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + ) + database.execSQL("CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `playlist_song_map` (`playlistId`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `playlist_song_map` (`songId`)") + database.execSQL("CREATE TABLE IF NOT EXISTS `download` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))") + database.execSQL( + "CREATE TABLE IF NOT EXISTS `search_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + ) + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `search_history` (`query`)") + database.execSQL("CREATE VIEW `sorted_song_artist_map` AS SELECT * FROM song_artist_map ORDER BY position") + database.execSQL( + "CREATE VIEW `playlist_song_map_preview` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position", + ) + artists.forEach { artist -> + database.insert( + "artist", + SQLiteDatabase.CONFLICT_ABORT, + contentValuesOf( + "id" to artist.id, + "name" to artist.name, + "createDate" to converters.dateToTimestamp(artist.lastUpdateTime), + "lastUpdateTime" to converters.dateToTimestamp(artist.lastUpdateTime), + ), + ) + } + songs.forEach { song -> + database.insert( + "song", + SQLiteDatabase.CONFLICT_ABORT, + contentValuesOf( + "id" to song.id, + "title" to song.title, + "duration" to song.duration, + "liked" to song.liked, + "totalPlayTime" to song.totalPlayTime, + "isTrash" to false, + "download_state" to song.downloadState, + "create_date" to converters.dateToTimestamp(song.createDate), + "modify_date" to converters.dateToTimestamp(song.modifyDate), + ), + ) + } + songArtistMaps.forEach { songArtistMap -> + database.insert( + "song_artist_map", + SQLiteDatabase.CONFLICT_ABORT, + contentValuesOf( + "songId" to songArtistMap.songId, + "artistId" to songArtistMap.artistId, + "position" to songArtistMap.position, + ), + ) + } + playlists.forEach { playlist -> + database.insert( + "playlist", + SQLiteDatabase.CONFLICT_ABORT, + contentValuesOf( + "id" to playlist.id, + "name" to playlist.name, + "createDate" to converters.dateToTimestamp(LocalDateTime.now()), + "lastUpdateTime" to converters.dateToTimestamp(LocalDateTime.now()), + ), + ) + } + playlistSongMaps.forEach { playlistSongMap -> + database.insert( + "playlist_song_map", + SQLiteDatabase.CONFLICT_ABORT, + contentValuesOf( + "playlistId" to playlistSongMap.playlistId, + "songId" to playlistSongMap.songId, + "position" to playlistSongMap.position, + ), + ) + } + } + } + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "isTrash"), + DeleteColumn(tableName = "playlist", columnName = "author"), + DeleteColumn(tableName = "playlist", columnName = "authorId"), + DeleteColumn(tableName = "playlist", columnName = "year"), + DeleteColumn(tableName = "playlist", columnName = "thumbnailUrl"), + DeleteColumn(tableName = "playlist", columnName = "createDate"), + DeleteColumn(tableName = "playlist", columnName = "lastUpdateTime"), +) +@RenameColumn.Entries( + RenameColumn( + tableName = "song", + fromColumnName = "download_state", + toColumnName = "downloadState" + ), + RenameColumn(tableName = "song", fromColumnName = "create_date", toColumnName = "createDate"), + RenameColumn(tableName = "song", fromColumnName = "modify_date", toColumnName = "modifyDate"), +) +class Migration5To6 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.query("SELECT id FROM playlist WHERE id NOT LIKE 'LP%'").use { cursor -> + while (cursor.moveToNext()) { + db.execSQL( + "UPDATE playlist SET browseID = '${cursor.getString(0)}' WHERE id = '${ + cursor.getString( + 0 + ) + }'" + ) + } + } + } +} + +class Migration6To7 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.query("SELECT id, createDate FROM song").use { cursor -> + while (cursor.moveToNext()) { + db.execSQL( + "UPDATE song SET inLibrary = ${cursor.getLong(1)} WHERE id = '${ + cursor.getString( + 0 + ) + }'" + ) + } + } + } +} + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "createDate"), + DeleteColumn(tableName = "song", columnName = "modifyDate"), +) +class Migration7To8 : AutoMigrationSpec + +@DeleteTable.Entries( + DeleteTable(tableName = "download"), +) +class Migration9To10 : AutoMigrationSpec + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "downloadState"), + DeleteColumn(tableName = "artist", columnName = "bannerUrl"), + DeleteColumn(tableName = "artist", columnName = "description"), + DeleteColumn(tableName = "artist", columnName = "createDate"), +) +class Migration10To11 : AutoMigrationSpec + +@DeleteColumn.Entries( + DeleteColumn(tableName = "album", columnName = "createDate"), +) +class Migration11To12 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE album SET bookmarkedAt = lastUpdateTime") + db.query("SELECT DISTINCT albumId, albumName FROM song").use { cursor -> + while (cursor.moveToNext()) { + val albumId = cursor.getString(0) + val albumName = cursor.getString(1) + db.insert( + table = "album", + conflictAlgorithm = SQLiteDatabase.CONFLICT_IGNORE, + values = + contentValuesOf( + "id" to albumId, + "title" to albumName, + "songCount" to 0, + "duration" to 0, + "lastUpdateTime" to 0, + ), + ) + } + } + db.query("CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `song` (`albumId`)") + } +} + +class Migration12To13 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + } +} + +class Migration13To14 : AutoMigrationSpec { + @SuppressLint("Range") + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE playlist SET createdAt = '${Converters().dateToTimestamp(LocalDateTime.now())}'") + db.execSQL( + "UPDATE playlist SET lastUpdateTime = '${ + Converters().dateToTimestamp( + LocalDateTime.now() + ) + }'" + ) + } +} + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "isLocal"), + DeleteColumn(tableName = "song", columnName = "localPath"), + DeleteColumn(tableName = "artist", columnName = "isLocal"), + DeleteColumn(tableName = "playlist", columnName = "isLocal"), +) +class Migration16To17 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE playlist SET bookmarkedAt = lastUpdateTime") + db.execSQL("UPDATE playlist SET isEditable = 1 WHERE browseId IS NOT NULL") + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/Album.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Album.kt new file mode 100644 index 00000000..9bad9d63 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Album.kt @@ -0,0 +1,33 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class Album( + @Embedded + val album: AlbumEntity, + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = + Junction( + value = AlbumArtistMap::class, + parentColumn = "albumId", + entityColumn = "artistId", + ), + ) + val artists: List, + val songCountListened: Int? = 0, + val timeListened: Int? = 0, +) : LocalItem() { + override val id: String + get() = album.id + override val title: String + get() = album.title + override val thumbnailUrl: String? + get() = album.thumbnailUrl +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumArtistMap.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumArtistMap.kt new file mode 100644 index 00000000..9d0693cb --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumArtistMap.kt @@ -0,0 +1,29 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "album_artist_map", + primaryKeys = ["albumId", "artistId"], + foreignKeys = [ + ForeignKey( + entity = AlbumEntity::class, + parentColumns = ["id"], + childColumns = ["albumId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = ArtistEntity::class, + parentColumns = ["id"], + childColumns = ["artistId"], + onDelete = ForeignKey.CASCADE, + ), + ] +) +data class AlbumArtistMap( + @ColumnInfo(index = true) val albumId: String, + @ColumnInfo(index = true) val artistId: String, + val order: Int, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumEntity.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumEntity.kt new file mode 100644 index 00000000..ea6a74fa --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumEntity.kt @@ -0,0 +1,40 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.darkxvenom.airbeats.innertube.YouTube +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@Immutable +@Entity(tableName = "album") +data class AlbumEntity( + @PrimaryKey val id: String, + val playlistId: String? = null, + val title: String, + val year: Int? = null, + val thumbnailUrl: String? = null, + val themeColor: Int? = null, + val songCount: Int, + val duration: Int, + val lastUpdateTime: LocalDateTime = LocalDateTime.now(), + val bookmarkedAt: LocalDateTime? = null, + val likedDate: LocalDateTime? = null, + val inLibrary: LocalDateTime? = null, +) { + fun localToggleLike() = copy( + bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now() + ) + + fun toggleLike() = localToggleLike().also { + CoroutineScope(Dispatchers.IO).launch { + if (playlistId != null) + YouTube.likePlaylist(playlistId, bookmarkedAt == null) + this.cancel() + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumWithSongs.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumWithSongs.kt new file mode 100644 index 00000000..f554c646 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/AlbumWithSongs.kt @@ -0,0 +1,36 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class AlbumWithSongs( + @Embedded + val album: AlbumEntity, + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = + Junction( + value = AlbumArtistMap::class, + parentColumn = "albumId", + entityColumn = "artistId", + ), + ) + val artists: List, + @Relation( + entity = SongEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = + Junction( + value = SortedSongAlbumMap::class, + parentColumn = "albumId", + entityColumn = "songId", + ), + ) + val songs: List, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/Artist.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Artist.kt new file mode 100644 index 00000000..77712fcd --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Artist.kt @@ -0,0 +1,21 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded + +@Immutable +data class Artist( + @Embedded + val artist: ArtistEntity, + val timeListened: Int? = 0, +) : LocalItem() { + override val id: String + get() = artist.id + override val title: String + get() = artist.name + override val thumbnailUrl: String? + get() = artist.thumbnailUrl + + val songCount: Int + get() = artist.songCount +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/ArtistEntity.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/ArtistEntity.kt new file mode 100644 index 00000000..4a90fe58 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/ArtistEntity.kt @@ -0,0 +1,54 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.darkxvenom.airbeats.innertube.YouTube +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.apache.commons.lang3.RandomStringUtils +import java.time.LocalDateTime + +@Immutable +@Entity(tableName = "artist") +data class ArtistEntity( + @PrimaryKey val id: String, + val name: String, + val thumbnailUrl: String? = null, + val channelId: String? = null, + @ColumnInfo(defaultValue = "0") + val songCount: Int = 0, + val lastUpdateTime: LocalDateTime = LocalDateTime.now(), + val bookmarkedAt: LocalDateTime? = null +) { + + val isYouTubeArtist: Boolean + get() = id.startsWith("UC") || id.startsWith("FEmusic_library_privately_owned_artist") + + val isPrivatelyOwnedArtist: Boolean + get() = id.startsWith("FEmusic_library_privately_owned_artist") + + val isLocalArtist: Boolean + get() = id.startsWith("LA") + + fun localToggleLike() = copy( + bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now(), + ) + + fun toggleLike() = localToggleLike().also { + CoroutineScope(Dispatchers.IO).launch { + if (channelId == null) + YouTube.subscribeChannel(YouTube.getChannelId(id), bookmarkedAt == null) + else + YouTube.subscribeChannel(channelId, bookmarkedAt == null) + this.cancel() + } + } + + companion object { + fun generateArtistId() = "LA" + RandomStringUtils.random(8, true, false) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/Event.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Event.kt new file mode 100644 index 00000000..c7bc58e3 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Event.kt @@ -0,0 +1,27 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import java.time.LocalDateTime + +@Immutable +@Entity( + tableName = "event", + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class Event( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songId: String, + val timestamp: LocalDateTime, + val playTime: Long, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/EventWithSong.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/EventWithSong.kt new file mode 100644 index 00000000..39a45683 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/EventWithSong.kt @@ -0,0 +1,17 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Relation + +@Immutable +data class EventWithSong( + @Embedded + val event: Event, + @Relation( + entity = SongEntity::class, + parentColumn = "songId", + entityColumn = "id", + ) + val song: Song, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/FormatEntity.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/FormatEntity.kt new file mode 100644 index 00000000..3a2dcbb4 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/FormatEntity.kt @@ -0,0 +1,17 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "format") +data class FormatEntity( + @PrimaryKey val id: String, + val itag: Int, + val mimeType: String, + val codecs: String, + val bitrate: Int, + val sampleRate: Int?, + val contentLength: Long, + val loudnessDb: Double?, + val playbackUrl: String? +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/LocalItem.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/LocalItem.kt new file mode 100644 index 00000000..f802039c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/LocalItem.kt @@ -0,0 +1,7 @@ +package com.darkxvenom.airbeats.db.entities + +sealed class LocalItem { + abstract val id: String + abstract val title: String + abstract val thumbnailUrl: String? +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/LyricsEntity.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/LyricsEntity.kt new file mode 100644 index 00000000..a0c31d8c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/LyricsEntity.kt @@ -0,0 +1,14 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "lyrics") +data class LyricsEntity( + @PrimaryKey val id: String, + val lyrics: String, +) { + companion object { + const val LYRICS_NOT_FOUND = " " + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlayCountEntity.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlayCountEntity.kt new file mode 100644 index 00000000..add8ef5c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlayCountEntity.kt @@ -0,0 +1,16 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Entity + +@Immutable +@Entity( + tableName = "playCount", + primaryKeys = ["song", "year", "month"] +) +class PlayCountEntity( + val song: String, // song id + val year: Int = -1, + val month: Int = -1, + val count: Int = -1, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/Playlist.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Playlist.kt new file mode 100644 index 00000000..406032aa --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Playlist.kt @@ -0,0 +1,33 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class Playlist( + @Embedded + val playlist: PlaylistEntity, + val songCount: Int, + @Relation( + entity = SongEntity::class, + entityColumn = "id", + parentColumn = "id", + projection = ["thumbnailUrl"], + associateBy = + Junction( + value = PlaylistSongMapPreview::class, + parentColumn = "playlistId", + entityColumn = "songId", + ), + ) + val thumbnails: List, +) : LocalItem() { + override val id: String + get() = playlist.id + override val title: String + get() = playlist.name + override val thumbnailUrl: String? + get() = null +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistEntity.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistEntity.kt new file mode 100644 index 00000000..bd7aa236 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistEntity.kt @@ -0,0 +1,56 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.darkxvenom.airbeats.innertube.YouTube +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.apache.commons.lang3.RandomStringUtils +import java.time.LocalDateTime + +@Immutable +@Entity(tableName = "playlist") +data class PlaylistEntity( + @PrimaryKey val id: String = generatePlaylistId(), + val name: String, + val browseId: String? = null, + val createdAt: LocalDateTime? = LocalDateTime.now(), + val lastUpdateTime: LocalDateTime? = LocalDateTime.now(), + @ColumnInfo(name = "isEditable", defaultValue = true.toString()) + val isEditable: Boolean = true, + val bookmarkedAt: LocalDateTime? = null, + val remoteSongCount: Int? = null, + val playEndpointParams: String? = null, + val shuffleEndpointParams: String? = null, + val radioEndpointParams: String? = null +) { + companion object { + const val LIKED_PLAYLIST_ID = "LP_LIKED" + const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED" + + fun generatePlaylistId() = "LP" + RandomStringUtils.random(8, true, false) + } + + val shareLink: String? + get() { + return if (browseId != null) + "https://music.youtube.com/playlist?list=$browseId" + else null + } + + fun localToggleLike() = copy( + bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now() + ) + + fun toggleLike() = localToggleLike().also { + CoroutineScope(Dispatchers.IO).launch { + if (browseId != null) + YouTube.likePlaylist(browseId, bookmarkedAt == null) + this.cancel() + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSong.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSong.kt new file mode 100644 index 00000000..76555759 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSong.kt @@ -0,0 +1,14 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.Embedded +import androidx.room.Relation + +data class PlaylistSong( + @Embedded val map: PlaylistSongMap, + @Relation( + parentColumn = "songId", + entityColumn = "id", + entity = SongEntity::class, + ) + val song: Song, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSongMap.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSongMap.kt new file mode 100644 index 00000000..6dfdeb7d --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSongMap.kt @@ -0,0 +1,31 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "playlist_song_map", + foreignKeys = [ + ForeignKey( + entity = PlaylistEntity::class, + parentColumns = ["id"], + childColumns = ["playlistId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class PlaylistSongMap( + @PrimaryKey(autoGenerate = true) val id: Int = 0, + @ColumnInfo(index = true) val playlistId: String, + @ColumnInfo(index = true) val songId: String, + val position: Int = 0, + val setVideoId: String? = null, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSongMapPreview.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSongMapPreview.kt new file mode 100644 index 00000000..1882a923 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/PlaylistSongMapPreview.kt @@ -0,0 +1,14 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "playlist_song_map_preview", + value = "SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position", +) +data class PlaylistSongMapPreview( + @ColumnInfo(index = true) val playlistId: String, + @ColumnInfo(index = true) val songId: String, + val idInPlaylist: Int = 0, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/RelatedSongMap.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/RelatedSongMap.kt new file mode 100644 index 00000000..041def6a --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/RelatedSongMap.kt @@ -0,0 +1,29 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "related_song_map", + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["relatedSongId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class RelatedSongMap( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val relatedSongId: String, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/SearchHistory.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SearchHistory.kt new file mode 100644 index 00000000..530e3aec --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SearchHistory.kt @@ -0,0 +1,19 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "search_history", + indices = [ + Index( + value = ["query"], + unique = true, + ), + ], +) +data class SearchHistory( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val query: String, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/SetVideoIdEntity.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SetVideoIdEntity.kt new file mode 100644 index 00000000..b9dbc60c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SetVideoIdEntity.kt @@ -0,0 +1,11 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "set_video_id") +data class SetVideoIdEntity( + @PrimaryKey(autoGenerate = false) + val videoId: String = "", + val setVideoId: String? = null, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/Song.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Song.kt new file mode 100644 index 00000000..6a30f219 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/Song.kt @@ -0,0 +1,44 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class Song +@JvmOverloads +constructor( + @Embedded val song: SongEntity, + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = + Junction( + value = SortedSongArtistMap::class, + parentColumn = "songId", + entityColumn = "artistId", + ), + ) + val artists: List, + @Relation( + entity = AlbumEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = + Junction( + value = SongAlbumMap::class, + parentColumn = "songId", + entityColumn = "albumId", + ), + ) + val album: AlbumEntity? = null, +) : LocalItem() { + override val id: String + get() = song.id + override val title: String + get() = song.title + override val thumbnailUrl: String? + get() = song.thumbnailUrl +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongAlbumMap.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongAlbumMap.kt new file mode 100644 index 00000000..8167dc35 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongAlbumMap.kt @@ -0,0 +1,29 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "song_album_map", + primaryKeys = ["songId", "albumId"], + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = AlbumEntity::class, + parentColumns = ["id"], + childColumns = ["albumId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class SongAlbumMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val albumId: String, + val index: Int, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongArtistMap.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongArtistMap.kt new file mode 100644 index 00000000..01321a5b --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongArtistMap.kt @@ -0,0 +1,29 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "song_artist_map", + primaryKeys = ["songId", "artistId"], + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = ArtistEntity::class, + parentColumns = ["id"], + childColumns = ["artistId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class SongArtistMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val artistId: String, + val position: Int, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongEntity.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongEntity.kt new file mode 100644 index 00000000..36b812b8 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongEntity.kt @@ -0,0 +1,60 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import com.darkxvenom.airbeats.innertube.YouTube +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@Immutable +@Entity( + tableName = "song", + indices = [ + Index( + value = ["albumId"] + ) + ] +) +data class SongEntity( + @PrimaryKey val id: String, + val title: String, + val duration: Int = -1, // in seconds + val thumbnailUrl: String? = null, + val albumId: String? = null, + val albumName: String? = null, + val year: Int? = null, + val date: LocalDateTime? = null, // ID3 tag property + val dateModified: LocalDateTime? = null, // file property + val liked: Boolean = false, + val likedDate: LocalDateTime? = null, + val totalPlayTime: Long = 0, // in milliseconds + val inLibrary: LocalDateTime? = null, + val dateDownload: LocalDateTime? = null, // doubles as "isDownloaded" +) { + fun localToggleLike() = copy( + liked = !liked, + likedDate = if (!liked) LocalDateTime.now() else null, + ) + + fun toggleLike() = copy( + liked = !liked, + likedDate = if (!liked) LocalDateTime.now() else null, + inLibrary = if (!liked) inLibrary ?: LocalDateTime.now() else inLibrary + ).also { + CoroutineScope(Dispatchers.IO).launch { + YouTube.likeVideo(id, !liked) + this.cancel() + } + } + + fun toggleLibrary() = copy( + liked = if (inLibrary == null) liked else false, + inLibrary = if (inLibrary == null) LocalDateTime.now() else null, + likedDate = if (inLibrary == null) likedDate else null + ) +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongWithStats.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongWithStats.kt new file mode 100644 index 00000000..6d4160a9 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SongWithStats.kt @@ -0,0 +1,12 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.compose.runtime.Immutable + +@Immutable +data class SongWithStats( + val id: String, + val title: String, + val thumbnailUrl: String, + val songCountListened: Int, + val timeListened: Long?, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/SortedSongAlbumMap.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SortedSongAlbumMap.kt new file mode 100644 index 00000000..8baa3238 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SortedSongAlbumMap.kt @@ -0,0 +1,14 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "sorted_song_album_map", + value = "SELECT * FROM song_album_map ORDER BY `index`", +) +data class SortedSongAlbumMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val albumId: String, + val index: Int, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/db/entities/SortedSongArtistMap.kt b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SortedSongArtistMap.kt new file mode 100644 index 00000000..06b918b2 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/db/entities/SortedSongArtistMap.kt @@ -0,0 +1,14 @@ +package com.darkxvenom.airbeats.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "sorted_song_artist_map", + value = "SELECT * FROM song_artist_map ORDER BY position", +) +data class SortedSongArtistMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val artistId: String, + val position: Int, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/di/AppModule.kt b/app/src/main/java/com/darkxvenom/airbeats/di/AppModule.kt new file mode 100644 index 00000000..eff83f07 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/di/AppModule.kt @@ -0,0 +1,79 @@ +package com.darkxvenom.airbeats.di + +import android.content.Context +import androidx.media3.database.DatabaseProvider +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.NoOpCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import com.darkxvenom.airbeats.constants.MaxSongCacheSizeKey +import com.darkxvenom.airbeats.db.InternalDatabase +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class PlayerCache + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DownloadCache + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Singleton + @Provides + fun provideDatabase( + @ApplicationContext context: Context, + ): MusicDatabase = InternalDatabase.newInstance(context) + + @Singleton + @Provides + fun provideDatabaseProvider( + @ApplicationContext context: Context, + ): DatabaseProvider = StandaloneDatabaseProvider(context) + + @Singleton + @Provides + @PlayerCache + fun providePlayerCache( + @ApplicationContext context: Context, + databaseProvider: DatabaseProvider, + ): SimpleCache { + val constructor = { + SimpleCache( + context.filesDir.resolve("exoplayer"), + when (val cacheSize = context.dataStore[MaxSongCacheSizeKey] ?: 1024) { + -1 -> NoOpCacheEvictor() + else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) + }, + databaseProvider, + ) + } + constructor().release() + return constructor() + } + + @Singleton + @Provides + @DownloadCache + fun provideDownloadCache( + @ApplicationContext context: Context, + databaseProvider: DatabaseProvider, + ): SimpleCache { + val constructor = { + SimpleCache(context.filesDir.resolve("download"), NoOpCacheEvictor(), databaseProvider) + } + constructor().release() + return constructor() + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/di/LyricsHelperEntryPoint.kt b/app/src/main/java/com/darkxvenom/airbeats/di/LyricsHelperEntryPoint.kt new file mode 100644 index 00000000..0d045f21 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/di/LyricsHelperEntryPoint.kt @@ -0,0 +1,12 @@ +package com.darkxvenom.airbeats.di + +import com.darkxvenom.airbeats.lyrics.LyricsHelper +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface LyricsHelperEntryPoint { + fun lyricsHelper(): LyricsHelper +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/di/PreferenceModule.kt b/app/src/main/java/com/darkxvenom/airbeats/di/PreferenceModule.kt new file mode 100644 index 00000000..fb9b96e4 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/di/PreferenceModule.kt @@ -0,0 +1,23 @@ +package com.darkxvenom.airbeats.di + +import android.content.Context +import com.darkxvenom.airbeats.ui.component.NamePreferenceManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PreferenceModule { + + @Provides + @Singleton + fun provideNamePreferenceManager( + @ApplicationContext context: Context + ): NamePreferenceManager { + return NamePreferenceManager(context) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/extensions/ContextExt.kt b/app/src/main/java/com/darkxvenom/airbeats/extensions/ContextExt.kt new file mode 100644 index 00000000..aef80729 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/extensions/ContextExt.kt @@ -0,0 +1,31 @@ +package com.darkxvenom.airbeats.extensions + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import com.darkxvenom.airbeats.innertube.utils.parseCookieString +import com.darkxvenom.airbeats.constants.InnerTubeCookieKey +import com.darkxvenom.airbeats.constants.YtmSyncKey +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import kotlinx.coroutines.runBlocking + +fun Context.isSyncEnabled(): Boolean { + return runBlocking { + dataStore.get(YtmSyncKey, true) && isUserLoggedIn() + } +} + +fun Context.isUserLoggedIn(): Boolean { + return runBlocking { + val cookie = dataStore[InnerTubeCookieKey] ?: "" + "SAPISID" in parseCookieString(cookie) && isInternetConnected() + } +} + +fun Context.isInternetConnected(): Boolean { + val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networkCapabilities = + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + return networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/extensions/CoroutineExt.kt b/app/src/main/java/com/darkxvenom/airbeats/extensions/CoroutineExt.kt new file mode 100644 index 00000000..7e6cf9df --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/extensions/CoroutineExt.kt @@ -0,0 +1,27 @@ +package com.darkxvenom.airbeats.extensions + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +fun Flow.collect( + scope: CoroutineScope, + action: suspend (value: T) -> Unit, +) { + scope.launch { + collect(action) + } +} + +fun Flow.collectLatest( + scope: CoroutineScope, + action: suspend (value: T) -> Unit, +) { + scope.launch { + collectLatest(action) + } +} + +val SilentHandler = CoroutineExceptionHandler { _, _ -> } diff --git a/app/src/main/java/com/darkxvenom/airbeats/extensions/FileExt.kt b/app/src/main/java/com/darkxvenom/airbeats/extensions/FileExt.kt new file mode 100644 index 00000000..669e0fe9 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/extensions/FileExt.kt @@ -0,0 +1,13 @@ +package com.darkxvenom.airbeats.extensions + +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +operator fun File.div(child: String): File = File(this, child) + +fun InputStream.zipInputStream(): ZipInputStream = ZipInputStream(this) + +fun OutputStream.zipOutputStream(): ZipOutputStream = ZipOutputStream(this) diff --git a/app/src/main/java/com/darkxvenom/airbeats/extensions/ListExt.kt b/app/src/main/java/com/darkxvenom/airbeats/extensions/ListExt.kt new file mode 100644 index 00000000..7584c516 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/extensions/ListExt.kt @@ -0,0 +1,34 @@ +package com.darkxvenom.airbeats.extensions + +fun List.reversed(reversed: Boolean) = if (reversed) asReversed() else this + +fun MutableList.move( + fromIndex: Int, + toIndex: Int, +): MutableList { + add(toIndex, removeAt(fromIndex)) + return this +} + +fun List.mergeNearbyElements( + key: (T) -> Any = { it }, + merge: (first: T, second: T) -> T = { first, _ -> first }, +): List { + if (isEmpty()) return emptyList() + + val mergedList = mutableListOf() + var currentItem = this[0] + + for (i in 1 until size) { + val nextItem = this[i] + if (key(currentItem) == key(nextItem)) { + currentItem = merge(currentItem, nextItem) + } else { + mergedList.add(currentItem) + currentItem = nextItem + } + } + mergedList.add(currentItem) + + return mergedList +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/extensions/MediaItemExt.kt b/app/src/main/java/com/darkxvenom/airbeats/extensions/MediaItemExt.kt new file mode 100644 index 00000000..fb14b499 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/extensions/MediaItemExt.kt @@ -0,0 +1,69 @@ +package com.darkxvenom.airbeats.extensions + +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.models.toMediaMetadata + +val MediaItem.metadata: MediaMetadata? + get() = localConfiguration?.tag as? MediaMetadata + +fun Song.toMediaItem() = + MediaItem + .Builder() + .setMediaId(song.id) + .setUri(song.id) + .setCustomCacheKey(song.id) + .setTag(toMediaMetadata()) + .setMediaMetadata( + androidx.media3.common.MediaMetadata + .Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setAlbumTitle(song.albumName) + .setMediaType(MEDIA_TYPE_MUSIC) + .build(), + ).build() + +fun SongItem.toMediaItem() = + MediaItem + .Builder() + .setMediaId(id) + .setUri(id) + .setCustomCacheKey(id) + .setTag(toMediaMetadata()) + .setMediaMetadata( + androidx.media3.common.MediaMetadata + .Builder() + .setTitle(title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(thumbnail.toUri()) + .setAlbumTitle(album?.name) + .setMediaType(MEDIA_TYPE_MUSIC) + .build(), + ).build() + +fun MediaMetadata.toMediaItem() = + MediaItem + .Builder() + .setMediaId(id) + .setUri(id) + .setCustomCacheKey(id) + .setTag(this) + .setMediaMetadata( + androidx.media3.common.MediaMetadata + .Builder() + .setTitle(title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(thumbnailUrl?.toUri()) + .setAlbumTitle(album?.title) + .setMediaType(MEDIA_TYPE_MUSIC) + .build(), + ).build() diff --git a/app/src/main/java/com/darkxvenom/airbeats/extensions/PlayerExt.kt b/app/src/main/java/com/darkxvenom/airbeats/extensions/PlayerExt.kt new file mode 100644 index 00000000..e86363ac --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/extensions/PlayerExt.kt @@ -0,0 +1,104 @@ +package com.darkxvenom.airbeats.extensions + +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.Timeline +import com.darkxvenom.airbeats.models.MediaMetadata +import java.util.ArrayDeque + +fun Player.togglePlayPause() { + if (!playWhenReady && playbackState == Player.STATE_IDLE) { + prepare() + } + playWhenReady = !playWhenReady +} + +fun Player.toggleRepeatMode() { + repeatMode = + when (repeatMode) { + REPEAT_MODE_OFF -> REPEAT_MODE_ALL + REPEAT_MODE_ALL -> REPEAT_MODE_ONE + REPEAT_MODE_ONE -> REPEAT_MODE_OFF + else -> throw IllegalStateException() + } +} + +fun Player.getQueueWindows(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = ArrayDeque() + val queueSize = timeline.windowCount + + val currentMediaItemIndex: Int = currentMediaItemIndex + queue.add(timeline.getWindow(currentMediaItemIndex, Timeline.Window())) + + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET) && queue.size < queueSize) { + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = + timeline.getNextWindowIndex(lastMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(timeline.getWindow(lastMediaItemIndex, Timeline.Window())) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET && queue.size < queueSize) { + firstMediaItemIndex = timeline.getPreviousWindowIndex( + firstMediaItemIndex, + REPEAT_MODE_OFF, + shuffleModeEnabled + ) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.addFirst(timeline.getWindow(firstMediaItemIndex, Timeline.Window())) + } + } + } + return queue.toList() +} + +fun Player.getCurrentQueueIndex(): Int { + if (currentTimeline.isEmpty) { + return -1 + } + var index = 0 + var currentMediaItemIndex = currentMediaItemIndex + while (currentMediaItemIndex != C.INDEX_UNSET) { + currentMediaItemIndex = currentTimeline.getPreviousWindowIndex( + currentMediaItemIndex, + REPEAT_MODE_OFF, + shuffleModeEnabled + ) + if (currentMediaItemIndex != C.INDEX_UNSET) { + index++ + } + } + return index +} + +val Player.currentMetadata: MediaMetadata? + get() = currentMediaItem?.metadata + +val Player.mediaItems: List + get() = + object : AbstractList() { + override val size: Int + get() = mediaItemCount + + override fun get(index: Int): MediaItem = getMediaItemAt(index) + } + +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/java/com/darkxvenom/airbeats/extensions/QueueExt.kt b/app/src/main/java/com/darkxvenom/airbeats/extensions/QueueExt.kt new file mode 100644 index 00000000..73815d0a --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/extensions/QueueExt.kt @@ -0,0 +1,113 @@ +package com.darkxvenom.airbeats.extensions + +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.models.PersistQueue +import com.darkxvenom.airbeats.models.QueueData +import com.darkxvenom.airbeats.models.QueueType +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.playback.queues.LocalAlbumRadio +import com.darkxvenom.airbeats.playback.queues.Queue +import com.darkxvenom.airbeats.playback.queues.YouTubeAlbumRadio +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue + + +fun Queue.toPersistQueue( + title: String?, + items: List, + mediaItemIndex: Int, + position: Long +): PersistQueue { + return when (this) { + is ListQueue -> PersistQueue( + title = title, + items = items, + mediaItemIndex = mediaItemIndex, + position = position, + queueType = QueueType.LIST + ) + is YouTubeQueue -> { + // Since endpoint is private, we'll store a simplified version + val endpoint = "youtube_queue" + PersistQueue( + title = title, + items = items, + mediaItemIndex = mediaItemIndex, + position = position, + queueType = QueueType.YOUTUBE, + queueData = QueueData.YouTubeData(endpoint = endpoint) + ) + } + is YouTubeAlbumRadio -> { + // Since playlistId is private, we'll store a simplified version + PersistQueue( + title = title, + items = items, + mediaItemIndex = mediaItemIndex, + position = position, + queueType = QueueType.YOUTUBE_ALBUM_RADIO, + queueData = QueueData.YouTubeAlbumRadioData( + playlistId = "youtube_album_radio" + ) + ) + } + is LocalAlbumRadio -> { + // Since albumWithSongs and startIndex are private, we'll store a simplified version + PersistQueue( + title = title, + items = items, + mediaItemIndex = mediaItemIndex, + position = position, + queueType = QueueType.LOCAL_ALBUM_RADIO, + queueData = QueueData.LocalAlbumRadioData( + albumId = "local_album_radio", + startIndex = 0 + ) + ) + } + else -> PersistQueue( + title = title, + items = items, + mediaItemIndex = mediaItemIndex, + position = position, + queueType = QueueType.LIST + ) + } +} + +fun PersistQueue.toQueue(): Queue { + return when (queueType) { + is QueueType.LIST -> ListQueue( + title = title, + items = items.map { it.toMediaItem() }, + startIndex = mediaItemIndex, + position = position + ) + is QueueType.YOUTUBE -> { + // For now, fallback to ListQueue since we can't reconstruct YouTubeQueue properly + ListQueue( + title = title, + items = items.map { it.toMediaItem() }, + startIndex = mediaItemIndex, + position = position + ) + } + is QueueType.YOUTUBE_ALBUM_RADIO -> { + // For now, fallback to ListQueue since we can't reconstruct YouTubeAlbumRadio properly + ListQueue( + title = title, + items = items.map { it.toMediaItem() }, + startIndex = mediaItemIndex, + position = position + ) + } + is QueueType.LOCAL_ALBUM_RADIO -> { + // For now, fallback to ListQueue since we can't reconstruct LocalAlbumRadio properly + ListQueue( + title = title, + items = items.map { it.toMediaItem() }, + startIndex = mediaItemIndex, + position = position + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/extensions/StringExt.kt b/app/src/main/java/com/darkxvenom/airbeats/extensions/StringExt.kt new file mode 100644 index 00000000..232d029c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/extensions/StringExt.kt @@ -0,0 +1,23 @@ +package com.darkxvenom.airbeats.extensions + +import androidx.sqlite.db.SimpleSQLiteQuery +import java.net.InetSocketAddress +import java.net.InetSocketAddress.createUnresolved + +inline fun > String?.toEnum(defaultValue: T): T = + if (this == null) { + defaultValue + } else { + try { + enumValueOf(this) + } catch (e: IllegalArgumentException) { + defaultValue + } + } + +fun String.toSQLiteQuery(): SimpleSQLiteQuery = SimpleSQLiteQuery(this) + +fun String.toInetSocketAddress(): InetSocketAddress { + val (host, port) = split(":") + return createUnresolved(host, port.toInt()) +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/extensions/UtilExt.kt b/app/src/main/java/com/darkxvenom/airbeats/extensions/UtilExt.kt new file mode 100644 index 00000000..6a5d7403 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/extensions/UtilExt.kt @@ -0,0 +1,8 @@ +package com.darkxvenom.airbeats.extensions + +fun tryOrNull(block: () -> T): T? = + try { + block() + } catch (e: Exception) { + null + } diff --git a/app/src/main/java/com/darkxvenom/airbeats/lyrics/KuGouLyricsProvider.kt b/app/src/main/java/com/darkxvenom/airbeats/lyrics/KuGouLyricsProvider.kt new file mode 100644 index 00000000..f977af5b --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/lyrics/KuGouLyricsProvider.kt @@ -0,0 +1,31 @@ +package com.darkxvenom.airbeats.lyrics + +import android.content.Context +import com.darkxvenom.airbeats.kugou.KuGou +import com.darkxvenom.airbeats.constants.EnableKugouKey +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get + +object KuGouLyricsProvider : LyricsProvider { + override val name = "Kugou" + override fun isEnabled(context: Context): Boolean = + context.dataStore[EnableKugouKey] ?: true + + override suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int + ): Result = + KuGou.getLyrics(title, artist, duration) + + override suspend fun getAllLyrics( + id: String, + title: String, + artist: String, + duration: Int, + callback: (String) -> Unit + ) { + KuGou.getAllPossibleLyricsOptions(title, artist, duration, callback) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/lyrics/LrcLibLyricsProvider.kt b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LrcLibLyricsProvider.kt new file mode 100644 index 00000000..c1709274 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LrcLibLyricsProvider.kt @@ -0,0 +1,30 @@ +package com.darkxvenom.airbeats.lyrics + +import android.content.Context +import com.darkxvenom.airbeats.lrclib.LrcLib +import com.darkxvenom.airbeats.constants.EnableLrcLibKey +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get + +object LrcLibLyricsProvider : LyricsProvider { + override val name = "LrcLib" + + override fun isEnabled(context: Context): Boolean = context.dataStore[EnableLrcLibKey] ?: true + + override suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int, + ): Result = LrcLib.getLyrics(title, artist, duration) + + override suspend fun getAllLyrics( + id: String, + title: String, + artist: String, + duration: Int, + callback: (String) -> Unit, + ) { + LrcLib.getAllLyrics(title, artist, duration, null, callback) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsEntry.kt b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsEntry.kt new file mode 100644 index 00000000..308cccdd --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsEntry.kt @@ -0,0 +1,12 @@ +package com.darkxvenom.airbeats.lyrics + +data class LyricsEntry( + val time: Long, + val text: String, +) : Comparable { + override fun compareTo(other: LyricsEntry): Int = (time - other.time).toInt() + + companion object { + val HEAD_LYRICS_ENTRY = LyricsEntry(0L, "") + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsHelper.kt b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsHelper.kt new file mode 100644 index 00000000..0372d293 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsHelper.kt @@ -0,0 +1,112 @@ +package com.darkxvenom.airbeats.lyrics + +import android.content.Context +import android.util.LruCache +import com.darkxvenom.airbeats.constants.PreferredLyricsProvider +import com.darkxvenom.airbeats.constants.PreferredLyricsProviderKey +import com.darkxvenom.airbeats.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND +import com.darkxvenom.airbeats.extensions.toEnum +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class LyricsHelper +@Inject +constructor( + @ApplicationContext private val context: Context, +) { + private var lyricsProviders = + listOf( + LrcLibLyricsProvider, + KuGouLyricsProvider, + YouTubeSubtitleLyricsProvider, + YouTubeLyricsProvider + ) + val preferred = + context.dataStore.data + .map { + it[PreferredLyricsProviderKey].toEnum(PreferredLyricsProvider.LRCLIB) + }.distinctUntilChanged() + .map { + lyricsProviders = + if (it == PreferredLyricsProvider.LRCLIB) { + listOf( + LrcLibLyricsProvider, + KuGouLyricsProvider, + YouTubeSubtitleLyricsProvider, + YouTubeLyricsProvider + ) + } else { + listOf( + KuGouLyricsProvider, + LrcLibLyricsProvider, + YouTubeSubtitleLyricsProvider, + YouTubeLyricsProvider + ) + } + } + private val cache = LruCache>(MAX_CACHE_SIZE) + + suspend fun getLyrics(mediaMetadata: MediaMetadata): String { + val cached = cache.get(mediaMetadata.id)?.firstOrNull() + if (cached != null) { + return cached.lyrics + } + lyricsProviders.forEach { provider -> + if (provider.isEnabled(context)) { + provider + .getLyrics( + mediaMetadata.id, + mediaMetadata.title, + mediaMetadata.artists.joinToString { it.name }, + mediaMetadata.duration, + ).onSuccess { lyrics -> + return lyrics + }.onFailure { + reportException(it) + } + } + } + return LYRICS_NOT_FOUND + } + + suspend fun getAllLyrics( + mediaId: String, + songTitle: String, + songArtists: String, + duration: Int, + callback: (LyricsResult) -> Unit, + ) { + val cacheKey = "$songArtists-$songTitle".replace(" ", "") + cache.get(cacheKey)?.let { results -> + results.forEach { + callback(it) + } + return + } + val allResult = mutableListOf() + lyricsProviders.forEach { provider -> + if (provider.isEnabled(context)) { + provider.getAllLyrics(mediaId, songTitle, songArtists, duration) { lyrics -> + val result = LyricsResult(provider.name, lyrics) + allResult += result + callback(result) + } + } + } + cache.put(cacheKey, allResult) + } + + companion object { + private const val MAX_CACHE_SIZE = 3 + } +} + +data class LyricsResult( + val providerName: String, + val lyrics: String, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsProvider.kt b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsProvider.kt new file mode 100644 index 00000000..aec618c1 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsProvider.kt @@ -0,0 +1,26 @@ +package com.darkxvenom.airbeats.lyrics + +import android.content.Context + +interface LyricsProvider { + val name: String + + fun isEnabled(context: Context): Boolean + + suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int, + ): Result + + suspend fun getAllLyrics( + id: String, + title: String, + artist: String, + duration: Int, + callback: (String) -> Unit, + ) { + getLyrics(id, title, artist, duration).onSuccess(callback) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsUtils.kt new file mode 100644 index 00000000..d80ed7eb --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/lyrics/LyricsUtils.kt @@ -0,0 +1,52 @@ +package com.darkxvenom.airbeats.lyrics + +import android.text.format.DateUtils +import com.darkxvenom.airbeats.ui.component.ANIMATE_SCROLL_DURATION + +@Suppress("RegExpRedundantEscape") +object LyricsUtils { + val LINE_REGEX = "((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\] ?)+)(.+)".toRegex() + val TIME_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]".toRegex() + + fun parseLyrics(lyrics: String): List = + lyrics + .lines() + .flatMap { line -> + parseLine(line).orEmpty() + }.sorted() + + private fun parseLine(line: String): List? { + if (line.isEmpty()) { + return null + } + val matchResult = LINE_REGEX.matchEntire(line.trim()) ?: return null + val times = matchResult.groupValues[1] + val text = matchResult.groupValues[3] + val timeMatchResults = TIME_REGEX.findAll(times) + + return timeMatchResults + .map { timeMatchResult -> + val min = timeMatchResult.groupValues[1].toLong() + val sec = timeMatchResult.groupValues[2].toLong() + val milString = timeMatchResult.groupValues[3] + var mil = milString.toLong() + if (milString.length == 2) { + mil *= 10 + } + val time = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil + LyricsEntry(time, text) + }.toList() + } + + fun findCurrentLineIndex( + lines: List, + position: Long, + ): Int { + for (index in lines.indices) { + if (lines[index].time >= position + ANIMATE_SCROLL_DURATION) { + return index - 1 + } + } + return lines.lastIndex + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/lyrics/YouTubeLyricsProvider.kt b/app/src/main/java/com/darkxvenom/airbeats/lyrics/YouTubeLyricsProvider.kt new file mode 100644 index 00000000..57ca7647 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/lyrics/YouTubeLyricsProvider.kt @@ -0,0 +1,26 @@ +package com.darkxvenom.airbeats.lyrics + +import android.content.Context +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint + +object YouTubeLyricsProvider : LyricsProvider { + override val name = "YouTube Music" + + override fun isEnabled(context: Context) = true + + override suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int, + ): Result = + runCatching { + val nextResult = YouTube.next(WatchEndpoint(videoId = id)).getOrThrow() + YouTube + .lyrics( + endpoint = nextResult.lyricsEndpoint + ?: throw IllegalStateException("Lyrics endpoint not found"), + ).getOrThrow() ?: throw IllegalStateException("Lyrics unavailable") + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/lyrics/YouTubeSubtitleLyricsProvider.kt b/app/src/main/java/com/darkxvenom/airbeats/lyrics/YouTubeSubtitleLyricsProvider.kt new file mode 100644 index 00000000..3af32a44 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/lyrics/YouTubeSubtitleLyricsProvider.kt @@ -0,0 +1,17 @@ +package com.darkxvenom.airbeats.lyrics + +import android.content.Context +import com.darkxvenom.airbeats.innertube.YouTube + +object YouTubeSubtitleLyricsProvider : LyricsProvider { + override val name = "YouTube Subtitle" + + override fun isEnabled(context: Context) = true + + override suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int, + ): Result = YouTube.transcript(id) +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/models/ItemsPage.kt b/app/src/main/java/com/darkxvenom/airbeats/models/ItemsPage.kt new file mode 100644 index 00000000..7a74c268 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/models/ItemsPage.kt @@ -0,0 +1,8 @@ +package com.darkxvenom.airbeats.models + +import com.darkxvenom.airbeats.innertube.models.YTItem + +data class ItemsPage( + val items: List, + val continuation: String?, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/models/MediaMetadata.kt b/app/src/main/java/com/darkxvenom/airbeats/models/MediaMetadata.kt new file mode 100644 index 00000000..85515207 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/models/MediaMetadata.kt @@ -0,0 +1,92 @@ +package com.darkxvenom.airbeats.models + +import androidx.compose.runtime.Immutable +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.db.entities.SongEntity +import com.darkxvenom.airbeats.ui.utils.resize +import java.io.Serializable + +@Immutable +data class MediaMetadata( + val id: String, + val title: String, + val artists: List, + val duration: Int, + val thumbnailUrl: String? = null, + val album: Album? = null, + val setVideoId: String? = null, + val explicit: Boolean = false, + val liked: Boolean = false, +) : Serializable { + data class Artist( + val id: String?, + val name: String, + ) : Serializable + + data class Album( + val id: String, + val title: String, + ) : Serializable + + fun toSongEntity() = + SongEntity( + id = id, + title = title, + duration = duration, + thumbnailUrl = thumbnailUrl, + albumId = album?.id, + albumName = album?.title, + ) +} + +fun Song.toMediaMetadata() = + MediaMetadata( + id = song.id, + title = song.title, + artists = + artists.map { + MediaMetadata.Artist( + id = it.id, + name = it.name, + ) + }, + duration = song.duration, + thumbnailUrl = song.thumbnailUrl, + album = + album?.let { + MediaMetadata.Album( + id = it.id, + title = it.title, + ) + } ?: song.albumId?.let { albumId -> + MediaMetadata.Album( + id = albumId, + title = song.albumName.orEmpty(), + ) + }, + ) + +fun SongItem.toMediaMetadata() = + MediaMetadata( + id = id, + title = title, + artists = + artists.map { + MediaMetadata.Artist( + id = it.id, + name = it.name, + ) + }, + duration = duration ?: -1, + thumbnailUrl = thumbnail.resize(544, 544), + album = + album?.let { + MediaMetadata.Album( + id = it.id, + title = it.name, + ) + }, + explicit = explicit, + setVideoId = setVideoId + ) diff --git a/app/src/main/java/com/darkxvenom/airbeats/models/PersistPlayerState.kt b/app/src/main/java/com/darkxvenom/airbeats/models/PersistPlayerState.kt new file mode 100644 index 00000000..5ea72d76 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/models/PersistPlayerState.kt @@ -0,0 +1,13 @@ +package com.darkxvenom.airbeats.models + +import java.io.Serializable + +data class PersistPlayerState( + val playWhenReady: Boolean, + val repeatMode: Int, + val shuffleModeEnabled: Boolean, + val volume: Float, + val currentPosition: Long, + val currentMediaItemIndex: Int, + val playbackState: Int +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/models/PersistQueue.kt b/app/src/main/java/com/darkxvenom/airbeats/models/PersistQueue.kt new file mode 100644 index 00000000..a72143d6 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/models/PersistQueue.kt @@ -0,0 +1,41 @@ +package com.darkxvenom.airbeats.models + +import java.io.Serializable + +data class PersistQueue( + val title: String?, + val items: List, + val mediaItemIndex: Int, + val position: Long, + val queueType: QueueType = QueueType.LIST, + val queueData: QueueData? = null, +) : Serializable + +sealed class QueueType : Serializable { + object LIST : QueueType() + object YOUTUBE : QueueType() + object YOUTUBE_ALBUM_RADIO : QueueType() + object LOCAL_ALBUM_RADIO : QueueType() +} + +sealed class QueueData : Serializable { + data class YouTubeData( + val endpoint: String, + val continuation: String? = null + ) : QueueData() + + data class YouTubeAlbumRadioData( + val playlistId: String, + val albumSongCount: Int = 0, + val continuation: String? = null, + val firstTimeLoaded: Boolean = false + ) : QueueData() + + data class LocalAlbumRadioData( + val albumId: String, + val startIndex: Int = 0, + val playlistId: String? = null, + val continuation: String? = null, + val firstTimeLoaded: Boolean = false + ) : QueueData() +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/models/SimilarRecommendation.kt b/app/src/main/java/com/darkxvenom/airbeats/models/SimilarRecommendation.kt new file mode 100644 index 00000000..3789b577 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/models/SimilarRecommendation.kt @@ -0,0 +1,9 @@ +package com.darkxvenom.airbeats.models + +import com.darkxvenom.airbeats.innertube.models.YTItem +import com.darkxvenom.airbeats.db.entities.LocalItem + +data class SimilarRecommendation( + val title: LocalItem, + val items: List, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/DownloadUtil.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/DownloadUtil.kt new file mode 100644 index 00000000..680da71d --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/DownloadUtil.kt @@ -0,0 +1,156 @@ +package com.darkxvenom.airbeats.playback + +import android.content.Context +import android.net.ConnectivityManager +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.media3.database.DatabaseProvider +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.constants.AudioQuality +import com.darkxvenom.airbeats.constants.AudioQualityKey +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.FormatEntity +import com.darkxvenom.airbeats.di.DownloadCache +import com.darkxvenom.airbeats.di.PlayerCache +import com.darkxvenom.airbeats.utils.YTPlayerUtils +import com.darkxvenom.airbeats.utils.enumPreference +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import java.util.concurrent.Executor +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DownloadUtil +@Inject +constructor( + @ApplicationContext context: Context, + val database: MusicDatabase, + val databaseProvider: DatabaseProvider, + @DownloadCache val downloadCache: SimpleCache, + @PlayerCache val playerCache: SimpleCache, +) { + private val connectivityManager = context.getSystemService()!! + private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO) + private val songUrlCache = HashMap>() + private val dataSourceFactory = + ResolvingDataSource.Factory( + CacheDataSource + .Factory() + .setCache(playerCache) + .setUpstreamDataSourceFactory( + OkHttpDataSource.Factory( + OkHttpClient + .Builder() + .proxy(YouTube.proxy) + .build(), + ), + ), + ) { dataSpec -> + val mediaId = dataSpec.key ?: error("No media id") + val length = if (dataSpec.length >= 0) dataSpec.length else 1 + + if (playerCache.isCached(mediaId, dataSpec.position, length)) { + return@Factory dataSpec + } + + songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let { + return@Factory dataSpec.withUri(it.first.toUri()) + } + + val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() } + val playbackData = runBlocking(Dispatchers.IO) { + YTPlayerUtils.playerResponseForPlayback( + mediaId, + audioQuality = audioQuality, + connectivityManager = connectivityManager, + ) + }.getOrThrow() + val format = playbackData.format + + database.query { + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playbackData.audioConfig?.loudnessDb, + playbackUrl = playbackData.streamUrl + ), + ) + } + + val streamUrl = playbackData.streamUrl.let { + // Specify range to avoid YouTube's throttling + "${it}&range=0-${format.contentLength ?: 10000000}" + } + + songUrlCache[mediaId] = + streamUrl to System.currentTimeMillis() + (playbackData.streamExpiresInSeconds * 1000L) + dataSpec.withUri(streamUrl.toUri()) + } + val downloadNotificationHelper = + DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID) + val downloadManager: DownloadManager = + DownloadManager( + context, + databaseProvider, + downloadCache, + dataSourceFactory, + Executor(Runnable::run) + ).apply { + maxParallelDownloads = 3 + addListener( + ExoDownloadService.TerminalStateNotificationHelper( + context = context, + notificationHelper = downloadNotificationHelper, + nextNotificationId = ExoDownloadService.NOTIFICATION_ID + 1, + ), + ) + } + val downloads = MutableStateFlow>(emptyMap()) + + fun getDownload(songId: String): Flow = downloads.map { it[songId] } + + init { + val result = mutableMapOf() + val cursor = downloadManager.downloadIndex.getDownloads() + while (cursor.moveToNext()) { + result[cursor.download.request.id] = cursor.download + } + downloads.value = result + downloadManager.addListener( + object : DownloadManager.Listener { + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception?, + ) { + downloads.update { map -> + map.toMutableMap().apply { + set(download.request.id, download) + } + } + } + }, + ) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/ExoDownloadService.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/ExoDownloadService.kt new file mode 100644 index 00000000..472a0d33 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/ExoDownloadService.kt @@ -0,0 +1,107 @@ +package com.darkxvenom.airbeats.playback + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Icon +import androidx.media3.common.util.NotificationUtil +import androidx.media3.common.util.Util +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import androidx.media3.exoplayer.offline.DownloadService +import androidx.media3.exoplayer.scheduler.PlatformScheduler +import androidx.media3.exoplayer.scheduler.Scheduler +import com.darkxvenom.airbeats.R +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + + +@AndroidEntryPoint +class ExoDownloadService : DownloadService( + NOTIFICATION_ID, + 1000L, + CHANNEL_ID, + R.string.download, + 0 +) { + @Inject + lateinit var downloadUtil: DownloadUtil + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == REMOVE_ALL_PENDING_DOWNLOADS) { + downloadManager.currentDownloads.forEach { download -> + downloadManager.removeDownload(download.request.id) + } + } + return super.onStartCommand(intent, flags, startId) + } + + override fun getDownloadManager() = downloadUtil.downloadManager + + override fun getScheduler(): Scheduler = PlatformScheduler(this, JOB_ID) + + override fun getForegroundNotification( + downloads: MutableList, + notMetRequirements: Int + ): Notification = + Notification.Builder.recoverBuilder( + this, downloadUtil.downloadNotificationHelper.buildProgressNotification( + this, + R.drawable.download, + null, + if (downloads.size == 1) Util.fromUtf8Bytes(downloads[0].request.data) + else resources.getQuantityString(R.plurals.n_song, downloads.size, downloads.size), + downloads, + notMetRequirements + ) + ).addAction( + Notification.Action.Builder( + Icon.createWithResource(this, R.drawable.close), + getString(android.R.string.cancel), + PendingIntent.getService( + this, + 0, + Intent(this, ExoDownloadService::class.java).setAction( + REMOVE_ALL_PENDING_DOWNLOADS + ), + PendingIntent.FLAG_IMMUTABLE + ) + ).build() + ).build() + + + /** + * This helper will outlive the lifespan of a single instance of + * [ExoDownloadService] + */ + class TerminalStateNotificationHelper( + private val context: Context, + private val notificationHelper: DownloadNotificationHelper, + private var nextNotificationId: Int, + ) : DownloadManager.Listener { + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception?, + ) { + if (download.state == Download.STATE_FAILED) { + val notification = notificationHelper.buildDownloadFailedNotification( + context, + R.drawable.error, + null, + Util.fromUtf8Bytes(download.request.data) + ) + NotificationUtil.setNotification(context, nextNotificationId++, notification) + } + } + } + + companion object { + const val CHANNEL_ID = "download" + const val NOTIFICATION_ID = 1 + const val JOB_ID = 1 + const val REMOVE_ALL_PENDING_DOWNLOADS = "REMOVE_ALL_PENDING_DOWNLOADS" + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/MediaLibrarySessionCallback.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/MediaLibrarySessionCallback.kt new file mode 100644 index 00000000..4afafd83 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/MediaLibrarySessionCallback.kt @@ -0,0 +1,429 @@ +package com.darkxvenom.airbeats.playback + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.os.Bundle +import androidx.annotation.DrawableRes +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.exoplayer.offline.Download +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionError +import androidx.media3.session.SessionResult +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.MediaSessionConstants +import com.darkxvenom.airbeats.constants.SongSortType +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.toggleRepeatMode +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.guava.future +import kotlinx.coroutines.plus +import javax.inject.Inject + +class MediaLibrarySessionCallback +@Inject +constructor( + @ApplicationContext val context: Context, + val database: MusicDatabase, + val downloadUtil: DownloadUtil, +) : MediaLibrarySession.Callback { + private val scope = CoroutineScope(Dispatchers.Main) + Job() + var toggleLike: () -> Unit = {} + var toggleLibrary: () -> Unit = {} + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo, + ): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + return MediaSession.ConnectionResult.accept( + connectionResult.availableSessionCommands + .buildUpon() + .add(MediaSessionConstants.CommandToggleLike) + .add(MediaSessionConstants.CommandToggleLibrary) + .add(MediaSessionConstants.CommandToggleShuffle) + .add(MediaSessionConstants.CommandToggleRepeatMode) + .build(), + connectionResult.availablePlayerCommands, + ) + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + when (customCommand.customAction) { + MediaSessionConstants.ACTION_TOGGLE_LIKE -> toggleLike() + MediaSessionConstants.ACTION_TOGGLE_LIBRARY -> toggleLibrary() + MediaSessionConstants.ACTION_TOGGLE_SHUFFLE -> session.player.shuffleModeEnabled = + !session.player.shuffleModeEnabled + + MediaSessionConstants.ACTION_TOGGLE_REPEAT_MODE -> session.player.toggleRepeatMode() + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: MediaLibraryService.LibraryParams?, + ): ListenableFuture> = + Futures.immediateFuture( + LibraryResult.ofItem( + MediaItem + .Builder() + .setMediaId(MusicService.ROOT) + .setMediaMetadata( + MediaMetadata + .Builder() + .setIsPlayable(false) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build(), + ).build(), + params, + ), + ) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: MediaLibraryService.LibraryParams?, + ): ListenableFuture>> = + scope.future(Dispatchers.IO) { + LibraryResult.ofItemList( + when (parentId) { + MusicService.ROOT -> + listOf( + browsableMediaItem( + MusicService.SONG, + context.getString(R.string.songs), + null, + drawableUri(R.drawable.music_note), + MediaMetadata.MEDIA_TYPE_PLAYLIST, + ), + browsableMediaItem( + MusicService.ARTIST, + context.getString(R.string.artists), + null, + drawableUri(R.drawable.artist), + MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, + ), + browsableMediaItem( + MusicService.ALBUM, + context.getString(R.string.albums), + null, + drawableUri(R.drawable.album), + MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, + ), + browsableMediaItem( + MusicService.PLAYLIST, + context.getString(R.string.playlists), + null, + drawableUri(R.drawable.queue_music), + MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS, + ), + ) + + MusicService.SONG -> database.songsByCreateDateAsc().first() + .map { it.toMediaItem(parentId) } + + MusicService.ARTIST -> + database.artistsInAA().first().map { artist -> + browsableMediaItem( + "${MusicService.ARTIST}/${artist.id}", + artist.artist.name, + context.resources.getQuantityString( + R.plurals.n_song, + artist.songCount, + artist.songCount + ), + artist.artist.thumbnailUrl?.toUri(), + MediaMetadata.MEDIA_TYPE_ARTIST, + ) + } + + MusicService.ALBUM -> + database.albumsInAA().first().map { album -> + browsableMediaItem( + "${MusicService.ALBUM}/${album.id}", + album.album.title, + album.artists.joinToString { + it.name + }, + album.album.thumbnailUrl?.toUri(), + MediaMetadata.MEDIA_TYPE_ALBUM, + ) + } + + MusicService.PLAYLIST -> { + val likedSongCount = database.likedSongsCount().first() + val downloadedSongCount = downloadUtil.downloads.value.size + listOf( + browsableMediaItem( + "${MusicService.PLAYLIST}/${PlaylistEntity.LIKED_PLAYLIST_ID}", + context.getString(R.string.liked_songs), + context.resources.getQuantityString( + R.plurals.n_song, + likedSongCount, + likedSongCount + ), + drawableUri(R.drawable.favorite), + MediaMetadata.MEDIA_TYPE_PLAYLIST, + ), + browsableMediaItem( + "${MusicService.PLAYLIST}/${PlaylistEntity.DOWNLOADED_PLAYLIST_ID}", + context.getString(R.string.downloaded_songs), + context.resources.getQuantityString( + R.plurals.n_song, + downloadedSongCount, + downloadedSongCount + ), + drawableUri(R.drawable.download), + MediaMetadata.MEDIA_TYPE_PLAYLIST, + ), + ) + + database.playlistsByCreateDateAsc().first().map { playlist -> + browsableMediaItem( + "${MusicService.PLAYLIST}/${playlist.id}", + playlist.playlist.name, + context.resources.getQuantityString( + R.plurals.n_song, + playlist.songCount, + playlist.songCount + ), + playlist.thumbnails.firstOrNull()?.toUri(), + MediaMetadata.MEDIA_TYPE_PLAYLIST, + ) + } + } + + else -> + when { + parentId.startsWith("${MusicService.ARTIST}/") -> + database.artistSongsInAA(parentId.removePrefix("${MusicService.ARTIST}/")) + .first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("${MusicService.ALBUM}/") -> + database.albumSongs(parentId.removePrefix("${MusicService.ALBUM}/")) + .first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("${MusicService.PLAYLIST}/") -> + when (val playlistId = + parentId.removePrefix("${MusicService.PLAYLIST}/")) { + PlaylistEntity.LIKED_PLAYLIST_ID -> database.likedSongs( + SongSortType.CREATE_DATE, + true + ) + + PlaylistEntity.DOWNLOADED_PLAYLIST_ID -> { + val downloads = downloadUtil.downloads.value + database + .allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + }.map { songs -> + songs + .map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } + } + + else -> + database.playlistSongs(playlistId).map { list -> + list.map { it.song } + } + }.first().map { + it.toMediaItem(parentId) + } + + else -> emptyList() + } + }, + params, + ) + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String, + ): ListenableFuture> = + scope.future(Dispatchers.IO) { + database.song(mediaId).first()?.toMediaItem()?.let { + LibraryResult.ofItem(it, null) + } ?: LibraryResult.ofError(SessionError.ERROR_UNKNOWN) + } + + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture = + scope.future { + // Play from Android Auto + val defaultResult = + MediaSession.MediaItemsWithStartPosition(emptyList(), startIndex, startPositionMs) + val path = + mediaItems.firstOrNull()?.mediaId?.split("/") + ?: return@future defaultResult + when (path.firstOrNull()) { + MusicService.SONG -> { + val songId = path.getOrNull(1) ?: return@future defaultResult + val allSongs = database.songsByCreateDateAsc().first() + MediaSession.MediaItemsWithStartPosition( + allSongs.map { it.toMediaItem() }, + allSongs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs, + ) + } + + MusicService.ARTIST -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val artistId = path.getOrNull(1) ?: return@future defaultResult + val songs = database.artistSongsByCreateDateAsc(artistId).first() + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs, + ) + } + + MusicService.ALBUM -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val albumId = path.getOrNull(1) ?: return@future defaultResult + val albumWithSongs = + database.albumWithSongs(albumId).first() ?: return@future defaultResult + MediaSession.MediaItemsWithStartPosition( + albumWithSongs.songs.map { it.toMediaItem() }, + albumWithSongs.songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } + ?: 0, + startPositionMs, + ) + } + + MusicService.PLAYLIST -> { + val songId = path.getOrNull(2) ?: return@future defaultResult + val playlistId = path.getOrNull(1) ?: return@future defaultResult + val songs = + when (playlistId) { + PlaylistEntity.LIKED_PLAYLIST_ID -> database.likedSongs( + SongSortType.CREATE_DATE, + descending = true + ) + + PlaylistEntity.DOWNLOADED_PLAYLIST_ID -> { + val downloads = downloadUtil.downloads.value + database + .allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + }.map { songs -> + songs + .map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } + } + + else -> + database.playlistSongs(playlistId).map { list -> + list.map { it.song } + } + }.first() + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs, + ) + } + + else -> defaultResult + } + } + + private fun drawableUri( + @DrawableRes id: Int, + ) = Uri + .Builder() + .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) + .authority(context.resources.getResourcePackageName(id)) + .appendPath(context.resources.getResourceTypeName(id)) + .appendPath(context.resources.getResourceEntryName(id)) + .build() + + private fun browsableMediaItem( + id: String, + title: String, + subtitle: String?, + iconUri: Uri?, + mediaType: Int = MediaMetadata.MEDIA_TYPE_MUSIC, + ) = MediaItem + .Builder() + .setMediaId(id) + .setMediaMetadata( + MediaMetadata + .Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setArtist(subtitle) + .setArtworkUri(iconUri) + .setIsPlayable(false) + .setIsBrowsable(true) + .setMediaType(mediaType) + .build(), + ).build() + + private fun Song.toMediaItem(path: String) = + MediaItem + .Builder() + .setMediaId("$path/$id") + .setMediaMetadata( + MediaMetadata + .Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setIsPlayable(true) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) + .build(), + ).build() +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/MusicService.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/MusicService.kt new file mode 100644 index 00000000..259b8e6c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/MusicService.kt @@ -0,0 +1,1611 @@ +@file:Suppress("DEPRECATION") + +package com.darkxvenom.airbeats.playback + +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.database.SQLException +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.media.audiofx.AudioEffect +import android.media.audiofx.LoudnessEnhancer +import android.net.ConnectivityManager +import android.os.Binder +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +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.Player.EVENT_POSITION_DISCONTINUITY +import androidx.media3.common.Player.EVENT_TIMELINE_CHANGED +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.Player.STATE_IDLE +import androidx.media3.common.Timeline +import androidx.media3.common.audio.SonicAudioProcessor +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +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.DefaultAudioSink +import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.mp4.FragmentedMp4Extractor +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.MediaController +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionToken +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.jossredconnect.JossRedClient +import com.darkxvenom.airbeats.MainActivity +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AudioNormalizationKey +import com.darkxvenom.airbeats.constants.AudioQualityKey +import com.darkxvenom.airbeats.constants.AutoLoadMoreKey +import com.darkxvenom.airbeats.constants.AutoSkipNextOnErrorKey +import com.darkxvenom.airbeats.constants.DisableLoadMoreWhenRepeatAllKey +import com.darkxvenom.airbeats.constants.DiscordTokenKey +import com.darkxvenom.airbeats.constants.DiscordUseDetailsKey +import com.darkxvenom.airbeats.constants.EnableDiscordRPCKey +import com.darkxvenom.airbeats.constants.HideExplicitKey +import com.darkxvenom.airbeats.constants.HistoryDuration +import com.darkxvenom.airbeats.constants.MediaSessionConstants.CommandToggleLike +import com.darkxvenom.airbeats.constants.MediaSessionConstants.CommandToggleRepeatMode +import com.darkxvenom.airbeats.constants.MediaSessionConstants.CommandToggleShuffle +import com.darkxvenom.airbeats.constants.PauseListenHistoryKey +import com.darkxvenom.airbeats.constants.PersistentQueueKey +import com.darkxvenom.airbeats.constants.PlayerVolumeKey +import com.darkxvenom.airbeats.constants.RepeatModeKey +import com.darkxvenom.airbeats.constants.ShowLyricsKey +import com.darkxvenom.airbeats.constants.SimilarContent +import com.darkxvenom.airbeats.constants.SkipSilenceKey +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.Event +import com.darkxvenom.airbeats.db.entities.FormatEntity +import com.darkxvenom.airbeats.db.entities.LyricsEntity +import com.darkxvenom.airbeats.db.entities.RelatedSongMap +import com.darkxvenom.airbeats.di.DownloadCache +import com.darkxvenom.airbeats.di.PlayerCache +import com.darkxvenom.airbeats.extensions.SilentHandler +import com.darkxvenom.airbeats.extensions.collect +import com.darkxvenom.airbeats.extensions.collectLatest +import com.darkxvenom.airbeats.extensions.currentMetadata +import com.darkxvenom.airbeats.extensions.findNextMediaItemById +import com.darkxvenom.airbeats.extensions.mediaItems +import com.darkxvenom.airbeats.extensions.metadata +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.toQueue +import com.darkxvenom.airbeats.lyrics.LyricsHelper +import com.darkxvenom.airbeats.models.PersistPlayerState +import com.darkxvenom.airbeats.models.PersistQueue +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.EmptyQueue +import com.darkxvenom.airbeats.playback.queues.Queue +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.playback.queues.filterExplicit +import com.darkxvenom.airbeats.utils.CoilBitmapLoader +import com.darkxvenom.airbeats.utils.DiscordRPC +import com.darkxvenom.airbeats.utils.NetworkConnectivityObserver +import com.darkxvenom.airbeats.utils.YTPlayerUtils +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.enumPreference +import com.darkxvenom.airbeats.utils.get +import com.darkxvenom.airbeats.utils.reportException +import com.google.common.util.concurrent.MoreExecutors +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@AndroidEntryPoint +class MusicService : + MediaLibraryService(), + Player.Listener, + PlaybackStatsListener.Callback { + @Inject + lateinit var database: MusicDatabase + + @Inject + lateinit var lyricsHelper: LyricsHelper + + @Inject + lateinit var mediaLibrarySessionCallback: MediaLibrarySessionCallback + + private lateinit var audioManager: AudioManager + private var audioFocusRequest: AudioFocusRequest? = null + private var lastAudioFocusState = AudioManager.AUDIOFOCUS_NONE + private var wasPlayingBeforeAudioFocusLoss = false + private var hasAudioFocus = false + + private var scope = CoroutineScope(Dispatchers.Main) + Job() + private val binder = MusicBinder() + + private lateinit var connectivityManager: ConnectivityManager + lateinit var connectivityObserver: NetworkConnectivityObserver + val waitingForNetworkConnection = MutableStateFlow(false) + private val isNetworkConnected = MutableStateFlow(false) + + private val audioQuality by enumPreference( + this, + AudioQualityKey, + com.darkxvenom.airbeats.constants.AudioQuality.AUTO + ) + + private var currentQueue: Queue = EmptyQueue + var queueTitle: String? = null + + val currentMediaMetadata = MutableStateFlow(null) + private val currentSong = + currentMediaMetadata + .flatMapLatest { mediaMetadata -> + database.song(mediaMetadata?.id) + }.stateIn(scope, SharingStarted.Lazily, null) + private val currentFormat = + currentMediaMetadata.flatMapLatest { mediaMetadata -> + database.format(mediaMetadata?.id) + } + + val playerVolume = MutableStateFlow(dataStore.get(PlayerVolumeKey, 1f).coerceIn(0f, 1f)) + + lateinit var sleepTimer: SleepTimer + + @Inject + @PlayerCache + lateinit var playerCache: SimpleCache + + @Inject + @DownloadCache + lateinit var downloadCache: SimpleCache + + lateinit var player: ExoPlayer + private lateinit var mediaSession: MediaLibrarySession + + private var isAudioEffectSessionOpened = false + private var loudnessEnhancer: LoudnessEnhancer? = null + + private var discordRpc: DiscordRPC? = null + private var lastPlaybackSpeed = 1.0f + private var discordUpdateJob: Job? = null + + val automixItems = MutableStateFlow>(emptyList()) + + private var consecutivePlaybackErr = 0 + + @RequiresApi(Build.VERSION_CODES.O) + override fun onCreate() { + super.onCreate() + setMediaNotificationProvider( + DefaultMediaNotificationProvider( + this, + { NOTIFICATION_ID }, + CHANNEL_ID, + R.string.music_player + ) + .apply { + setSmallIcon(R.drawable.airbeats_monochrome) + }, + ) + player = + ExoPlayer + .Builder(this) + .setMediaSourceFactory(createMediaSourceFactory()) + .setRenderersFactory(createRenderersFactory()) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setAudioAttributes( + AudioAttributes + .Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), + false, + ).setSeekBackIncrementMs(5000) + .setSeekForwardIncrementMs(5000) + .build() + .apply { + addListener(this@MusicService) + sleepTimer = SleepTimer(scope, this) + addListener(sleepTimer) + addAnalyticsListener(PlaybackStatsListener(false, this@MusicService)) + } + + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + setupAudioFocusRequest() + + mediaLibrarySessionCallback.apply { + toggleLike = ::toggleLike + toggleLibrary = ::toggleLibrary + } + mediaSession = + MediaLibrarySession + .Builder(this, player, mediaLibrarySessionCallback) + .setSessionActivity( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE, + ), + ).setBitmapLoader(CoilBitmapLoader(this, scope)) + .build() + player.repeatMode = dataStore.get(RepeatModeKey, REPEAT_MODE_OFF) + + // Keep a connected controller so that notification works + val sessionToken = SessionToken(this, ComponentName(this, MusicService::class.java)) + val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + controllerFuture.addListener({ controllerFuture.get() }, MoreExecutors.directExecutor()) + + connectivityManager = getSystemService()!! + connectivityObserver = NetworkConnectivityObserver(this) + + // Observar conectividad de red + scope.launch { + connectivityObserver.networkStatus.collect { isConnected -> + isNetworkConnected.value = isConnected + if (isConnected && waitingForNetworkConnection.value) { + // Reintentar reproducción cuando vuelve la conexión + waitingForNetworkConnection.value = false + if (player.currentMediaItem != null && player.playWhenReady) { + player.prepare() + player.play() + } + } + } + } + + playerVolume.collectLatest(scope) { + player.volume = it + } + + playerVolume.debounce(1000).collect(scope) { volume -> + dataStore.edit { settings -> + settings[PlayerVolumeKey] = volume + } + } + + currentSong.debounce(1000).collect(scope) { song -> + updateNotification() + if (song != null && player.playWhenReady && player.playbackState == Player.STATE_READY) { + discordRpc?.updateSong(song, player.currentPosition, player.playbackParameters.speed, dataStore.get(DiscordUseDetailsKey, false)) + } else { + discordRpc?.closeRPC() + } + } + + combine( + currentMediaMetadata.distinctUntilChangedBy { it?.id }, + dataStore.data.map { it[ShowLyricsKey] ?: false }.distinctUntilChanged(), + ) { mediaMetadata, showLyrics -> + mediaMetadata to showLyrics + }.collectLatest(scope) { (mediaMetadata, showLyrics) -> + if (showLyrics && mediaMetadata != null && database.lyrics(mediaMetadata.id) + .first() == null + ) { + val lyrics = lyricsHelper.getLyrics(mediaMetadata) + database.query { + upsert( + LyricsEntity( + id = mediaMetadata.id, + lyrics = lyrics, + ), + ) + } + } + } + + dataStore.data + .map { it[SkipSilenceKey] ?: false } + .distinctUntilChanged() + .collectLatest(scope) { + player.skipSilenceEnabled = it + } + + combine( + currentFormat, + dataStore.data + .map { it[AudioNormalizationKey] ?: true } + .distinctUntilChanged(), + ) { format, normalizeAudio -> + format to normalizeAudio + }.collectLatest(scope) { (format, normalizeAudio) -> + setupLoudnessEnhancer() + } + + dataStore.data + .map { it[DiscordTokenKey] to (it[EnableDiscordRPCKey] ?: true) } + .debounce(300) + .distinctUntilChanged() + .collect(scope) { (key, enabled) -> + if (discordRpc?.isRpcRunning() == true) { + discordRpc?.closeRPC() + } + discordRpc = null + if (key != null && enabled) { + discordRpc = DiscordRPC(this, key) + if (player.playbackState == Player.STATE_READY && player.playWhenReady) { + currentSong.value?.let { + discordRpc?.updateSong(it, player.currentPosition, player.playbackParameters.speed, dataStore.get(DiscordUseDetailsKey, false)) + } + } + } + } + + // Observar cambios en DiscordUseDetailsKey + dataStore.data + .map { it[DiscordUseDetailsKey] ?: false } + .debounce(1000) + .distinctUntilChanged() + .collect(scope) { useDetails -> + if (player.playbackState == Player.STATE_READY && player.playWhenReady) { + currentSong.value?.let { song -> + discordUpdateJob?.cancel() + discordUpdateJob = scope.launch { + delay(1000) + discordRpc?.updateSong(song, player.currentPosition, player.playbackParameters.speed, useDetails) + } + } + } + } + + if (dataStore.get(PersistentQueueKey, true)) { + runCatching { + filesDir.resolve(PERSISTENT_QUEUE_FILE).inputStream().use { fis -> + ObjectInputStream(fis).use { oos -> + oos.readObject() as PersistQueue + } + } + }.onSuccess { queue -> + // Convertir de vuelta al tipo de cola apropiado + val restoredQueue = queue.toQueue() + playQueue( + queue = restoredQueue, + playWhenReady = false, + ) + } + runCatching { + filesDir.resolve(PERSISTENT_AUTOMIX_FILE).inputStream().use { fis -> + ObjectInputStream(fis).use { oos -> + oos.readObject() as PersistQueue + } + } + }.onSuccess { queue -> + automixItems.value = queue.items.map { it.toMediaItem() } + } + + // Restaurar estado del reproductor + runCatching { + filesDir.resolve(PERSISTENT_PLAYER_STATE_FILE).inputStream().use { fis -> + ObjectInputStream(fis).use { oos -> + oos.readObject() as PersistPlayerState + } + } + }.onSuccess { playerState -> + // Restaurar configuración del reproductor después de cargar la cola + scope.launch { + delay(1000) // Esperar a que la cola se cargue + player.repeatMode = playerState.repeatMode + player.shuffleModeEnabled = playerState.shuffleModeEnabled + player.volume = playerState.volume + + // Restaurar posición si sigue siendo válida + if (playerState.currentMediaItemIndex < player.mediaItemCount) { + player.seekTo(playerState.currentMediaItemIndex, playerState.currentPosition) + } + } + } + } + + // Guardar cola periódicamente para prevenir pérdida por crash o force kill + scope.launch { + while (isActive) { + delay(30.seconds) + if (dataStore.get(PersistentQueueKey, true)) { + saveQueueToDisk() + } + } + } + + // Guardar cola más frecuentemente cuando está reproduciendo + scope.launch { + while (isActive) { + delay(10.seconds) + if (dataStore.get(PersistentQueueKey, true) && player.isPlaying) { + saveQueueToDisk() + } + } + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun setupAudioFocusRequest() { + audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes( + android.media.AudioAttributes.Builder() + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC) + .build() + ) + .setOnAudioFocusChangeListener { focusChange -> + handleAudioFocusChange(focusChange) + } + .setAcceptsDelayedFocusGain(true) + .build() + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun handleAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + hasAudioFocus = true + + if (wasPlayingBeforeAudioFocusLoss) { + player.play() + wasPlayingBeforeAudioFocusLoss = false + } + + player.volume = playerVolume.value + + lastAudioFocusState = focusChange + } + + AudioManager.AUDIOFOCUS_LOSS -> { + hasAudioFocus = false + wasPlayingBeforeAudioFocusLoss = false + + if (player.isPlaying) { + player.pause() + } + + abandonAudioFocus() + + lastAudioFocusState = focusChange + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + hasAudioFocus = false + wasPlayingBeforeAudioFocusLoss = player.isPlaying + + if (player.isPlaying) { + player.pause() + } + + lastAudioFocusState = focusChange + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + hasAudioFocus = false + wasPlayingBeforeAudioFocusLoss = player.isPlaying + + if (player.isPlaying) { + player.volume = (playerVolume.value * 0.2f) + } + + lastAudioFocusState = focusChange + } + + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> { + hasAudioFocus = true + + if (wasPlayingBeforeAudioFocusLoss) { + player.play() + wasPlayingBeforeAudioFocusLoss = false + } + + player.volume = playerVolume.value + + lastAudioFocusState = focusChange + } + + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> { + hasAudioFocus = true + + player.volume = playerVolume.value + + lastAudioFocusState = focusChange + } + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun requestAudioFocus(): Boolean { + if (hasAudioFocus) return true + + audioFocusRequest?.let { request -> + val result = audioManager.requestAudioFocus(request) + hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + return hasAudioFocus + } + return false + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun abandonAudioFocus() { + if (hasAudioFocus) { + audioFocusRequest?.let { request -> + audioManager.abandonAudioFocusRequest(request) + hasAudioFocus = false + } + } + } + + fun hasAudioFocusForPlayback(): Boolean { + return hasAudioFocus + } + + private fun waitOnNetworkError() { + waitingForNetworkConnection.value = true + } + + private fun skipOnError() { + /** + * Auto skip to the next media item on error. + * + * To prevent a "runaway diesel engine" scenario, force the user to take action after + * too many errors come up too quickly. Pause to show player "stopped" state + */ + consecutivePlaybackErr += 2 + val nextWindowIndex = player.nextMediaItemIndex + + if (consecutivePlaybackErr <= MAX_CONSECUTIVE_ERR && nextWindowIndex != C.INDEX_UNSET) { + player.seekTo(nextWindowIndex, C.TIME_UNSET) + player.prepare() + player.play() + return + } + + player.pause() + consecutivePlaybackErr = 0 + } + + private fun stopOnError() { + player.pause() + } + + private fun updateNotification() { + mediaSession.setCustomLayout( + listOf( + CommandButton + .Builder() + .setDisplayName( + getString( + if (currentSong.value?.song?.liked == + true + ) { + R.string.action_remove_like + } else { + R.string.action_like + }, + ), + ) + .setIconResId(if (currentSong.value?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border) + .setSessionCommand(CommandToggleLike) + .setEnabled(currentSong.value != null) + .build(), + CommandButton + .Builder() + .setDisplayName( + getString( + when (player.repeatMode) { + REPEAT_MODE_OFF -> R.string.repeat_mode_off + REPEAT_MODE_ONE -> R.string.repeat_mode_one + REPEAT_MODE_ALL -> R.string.repeat_mode_all + else -> throw IllegalStateException() + }, + ), + ).setIconResId( + when (player.repeatMode) { + REPEAT_MODE_OFF -> R.drawable.repeat + REPEAT_MODE_ONE -> R.drawable.repeat_one_on + REPEAT_MODE_ALL -> R.drawable.repeat_on + else -> throw IllegalStateException() + }, + ).setSessionCommand(CommandToggleRepeatMode) + .build(), + CommandButton + .Builder() + .setDisplayName(getString(if (player.shuffleModeEnabled) R.string.action_shuffle_off else R.string.action_shuffle_on)) + .setIconResId(if (player.shuffleModeEnabled) R.drawable.shuffle_on else R.drawable.shuffle) + .setSessionCommand(CommandToggleShuffle) + .build(), + ), + ) + } + + private suspend fun recoverSong( + mediaId: String, + playbackData: YTPlayerUtils.PlaybackData? = null + ) { + val song = database.song(mediaId).first() + val mediaMetadata = withContext(Dispatchers.Main) { + player.findNextMediaItemById(mediaId)?.metadata + } ?: return + val duration = song?.song?.duration?.takeIf { it != -1 } + ?: mediaMetadata.duration.takeIf { it != -1 } + ?: (playbackData?.videoDetails ?: YTPlayerUtils.playerResponseForMetadata(mediaId) + .getOrNull()?.videoDetails)?.lengthSeconds?.toInt() + ?: -1 + database.query { + if (song == null) insert(mediaMetadata.copy(duration = duration)) + else if (song.song.duration == -1) update(song.song.copy(duration = duration)) + } + if (!database.hasRelatedSongs(mediaId)) { + val relatedEndpoint = + YouTube.next(WatchEndpoint(videoId = mediaId)).getOrNull()?.relatedEndpoint + ?: return + val relatedPage = YouTube.related(relatedEndpoint).getOrNull() ?: return + database.query { + relatedPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .map { + RelatedSongMap( + songId = mediaId, + relatedSongId = it.id + ) + } + .forEach(::insert) + } + } + } + + fun playQueue( + queue: Queue, + playWhenReady: Boolean = true, + ) { + if (!scope.isActive) scope = CoroutineScope(Dispatchers.Main) + Job() + currentQueue = queue + queueTitle = null + player.shuffleModeEnabled = false + if (queue.preloadItem != null) { + player.setMediaItem(queue.preloadItem!!.toMediaItem()) + player.prepare() + player.playWhenReady = playWhenReady + } + scope.launch(SilentHandler) { + val initialStatus = + withContext(Dispatchers.IO) { + queue.getInitialStatus().filterExplicit(dataStore.get(HideExplicitKey, false)) + } + if (queue.preloadItem != null && player.playbackState == STATE_IDLE) return@launch + if (initialStatus.title != null) { + queueTitle = initialStatus.title + } + if (initialStatus.items.isEmpty()) return@launch + if (queue.preloadItem != null) { + player.addMediaItems( + 0, + initialStatus.items.subList(0, initialStatus.mediaItemIndex) + ) + player.addMediaItems( + initialStatus.items.subList( + initialStatus.mediaItemIndex + 1, + initialStatus.items.size + ) + ) + } else { + player.setMediaItems( + initialStatus.items, + if (initialStatus.mediaItemIndex > + 0 + ) { + initialStatus.mediaItemIndex + } else { + 0 + }, + initialStatus.position, + ) + player.prepare() + player.playWhenReady = playWhenReady + } + } + } + + fun startRadioSeamlessly() { + val currentMediaMetadata = player.currentMetadata ?: return + + // Guardar canción actual + val currentSong = player.currentMediaItem + + // Remover otras canciones de la cola + if (player.currentMediaItemIndex > 0) { + player.removeMediaItems(0, player.currentMediaItemIndex) + } + if (player.currentMediaItemIndex < player.mediaItemCount - 1) { + player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount) + } + + scope.launch(SilentHandler) { + val radioQueue = YouTubeQueue( + endpoint = WatchEndpoint(videoId = currentMediaMetadata.id) + ) + val initialStatus = radioQueue.getInitialStatus() + + if (initialStatus.title != null) { + queueTitle = initialStatus.title + } + + // Agregar canciones de radio después de la canción actual + player.addMediaItems(initialStatus.items.drop(1)) + currentQueue = radioQueue + } + } + + fun getAutomixAlbum(albumId: String) { + scope.launch(SilentHandler) { + YouTube + .album(albumId) + .onSuccess { + getAutomix(it.album.playlistId) + } + } + } + + fun getAutomix(playlistId: String) { + if (dataStore[SimilarContent] == true && + !(dataStore.get(DisableLoadMoreWhenRepeatAllKey, false) && player.repeatMode == REPEAT_MODE_ALL)) { + scope.launch(SilentHandler) { + YouTube + .next(WatchEndpoint(playlistId = playlistId)) + .onSuccess { + YouTube + .next(WatchEndpoint(playlistId = it.endpoint.playlistId)) + .onSuccess { + automixItems.value = + it.items.map { song -> + song.toMediaItem() + } + } + } + } + } + } + + fun addToQueueAutomix( + item: MediaItem, + position: Int, + ) { + automixItems.value = + automixItems.value.toMutableList().apply { + removeAt(position) + } + addToQueue(listOf(item)) + } + + fun playNextAutomix( + item: MediaItem, + position: Int, + ) { + automixItems.value = + automixItems.value.toMutableList().apply { + removeAt(position) + } + playNext(listOf(item)) + } + + fun clearAutomix() { + automixItems.value = emptyList() + } + + fun playNext(items: List) { + // Si la cola está vacía o el reproductor está inactivo, reproducir inmediatamente + if (player.mediaItemCount == 0 || player.playbackState == STATE_IDLE) { + player.setMediaItems(items) + player.prepare() + player.play() + return + } + + val insertIndex = player.currentMediaItemIndex + 1 + val shuffleEnabled = player.shuffleModeEnabled + + // Insertar items inmediatamente después del item actual en el espacio de ventana/índice + player.addMediaItems(insertIndex, items) + player.prepare() + + if (shuffleEnabled) { + // Reconstruir orden aleatorio para que los items recién insertados se reproduzcan a continuación + val timeline = player.currentTimeline + if (!timeline.isEmpty) { + val size = timeline.windowCount + val currentIndex = player.currentMediaItemIndex + + // Los índices recién insertados son un rango contiguo [insertIndex, insertIndex + items.size) + val newIndices = (insertIndex until (insertIndex + items.size)).toSet() + + // Recopilar el orden de recorrido aleatorio existente excluyendo el índice actual + val orderAfter = mutableListOf() + var idx = currentIndex + while (true) { + idx = timeline.getNextWindowIndex(idx, Player.REPEAT_MODE_OFF, /*shuffleModeEnabled=*/true) + if (idx == C.INDEX_UNSET) break + if (idx != currentIndex) orderAfter.add(idx) + } + + val prevList = mutableListOf() + var pIdx = currentIndex + while (true) { + pIdx = timeline.getPreviousWindowIndex(pIdx, Player.REPEAT_MODE_OFF, /*shuffleModeEnabled=*/true) + if (pIdx == C.INDEX_UNSET) break + if (pIdx != currentIndex) prevList.add(pIdx) + } + prevList.reverse() // preservar el orden hacia adelante original + + val existingOrder = (prevList + orderAfter).filter { it != currentIndex && it !in newIndices } + + // Construir nuevo orden aleatorio: actual -> recién insertados (en orden de inserción) -> resto + val nextBlock = (insertIndex until (insertIndex + items.size)).toList() + val finalOrder = IntArray(size) + var pos = 0 + finalOrder[pos++] = currentIndex + nextBlock.forEach { if (it in 0 until size) finalOrder[pos++] = it } + existingOrder.forEach { if (pos < size) finalOrder[pos++] = it } + + // Llenar cualquier índice faltante (seguridad) para asegurar una permutación completa + if (pos < size) { + for (i in 0 until size) { + if (!finalOrder.contains(i)) { + finalOrder[pos++] = i + if (pos == size) break + } + } + } + + player.setShuffleOrder(DefaultShuffleOrder(finalOrder, System.currentTimeMillis())) + } + } + } + + fun addToQueue(items: List) { + player.addMediaItems(items) + player.prepare() + } + + private fun toggleLibrary() { + database.query { + currentSong.value?.let { + update(it.song.toggleLibrary()) + } + } + } + + fun toggleLike() { + database.query { + currentSong.value?.let { + update(it.song.toggleLike()) + } + } + } + + private fun setupLoudnessEnhancer() { + val audioSessionId = player.audioSessionId + + if (audioSessionId == C.AUDIO_SESSION_ID_UNSET || audioSessionId <= 0) { + Log.w(TAG, "setupLoudnessEnhancer: invalid audioSessionId ($audioSessionId), cannot create effect yet") + return + } + + // Crear o recrear enhancer si es necesario + if (loudnessEnhancer == null) { + try { + loudnessEnhancer = LoudnessEnhancer(audioSessionId) + Log.d(TAG, "LoudnessEnhancer created for sessionId=$audioSessionId") + } catch (e: Exception) { + reportException(e) + loudnessEnhancer = null + return + } + } + + scope.launch { + try { + val currentMediaId = withContext(Dispatchers.Main) { + player.currentMediaItem?.mediaId + } + + val normalizeAudio = withContext(Dispatchers.IO) { + dataStore.data.map { it[AudioNormalizationKey] ?: true }.first() + } + + if (normalizeAudio && currentMediaId != null) { + val format = withContext(Dispatchers.IO) { + database.format(currentMediaId).first() + } + + val loudnessDb = format?.loudnessDb + + withContext(Dispatchers.Main) { + if (loudnessDb != null) { + val targetGain = (-loudnessDb * 100).toInt() + val clampedGain = targetGain.coerceIn(MIN_GAIN_MB, MAX_GAIN_MB) + try { + loudnessEnhancer?.setTargetGain(clampedGain) + loudnessEnhancer?.enabled = true + Log.d(TAG, "LoudnessEnhancer gain applied: $clampedGain mB") + } catch (e: Exception) { + reportException(e) + releaseLoudnessEnhancer() + } + } else { + loudnessEnhancer?.enabled = false + Log.w(TAG, "setupLoudnessEnhancer: loudnessDb is null, enhancer disabled") + } + } + } else { + withContext(Dispatchers.Main) { + loudnessEnhancer?.enabled = false + Log.d(TAG, "setupLoudnessEnhancer: normalization disabled or mediaId unavailable") + } + } + } catch (e: Exception) { + reportException(e) + releaseLoudnessEnhancer() + } + } + } + + private fun releaseLoudnessEnhancer() { + try { + loudnessEnhancer?.release() + Log.d(TAG, "LoudnessEnhancer released") + } catch (e: Exception) { + reportException(e) + Log.e(TAG, "Error releasing LoudnessEnhancer: ${e.message}") + } finally { + loudnessEnhancer = null + } + } + + private fun openAudioEffectSession() { + if (isAudioEffectSessionOpened) return + isAudioEffectSessionOpened = true + setupLoudnessEnhancer() + 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 closeAudioEffectSession() { + if (!isAudioEffectSessionOpened) return + isAudioEffectSessionOpened = false + releaseLoudnessEnhancer() + sendBroadcast( + Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + }, + ) + } + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + lastPlaybackSpeed = -1.0f // forzar actualización de canción + + setupLoudnessEnhancer() + + discordUpdateJob?.cancel() + + // Resetear errores consecutivos cuando hay transición exitosa + consecutivePlaybackErr = 0 + + // Auto cargar más canciones + if (dataStore.get(AutoLoadMoreKey, true) && + reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT && + player.mediaItemCount - player.currentMediaItemIndex <= 5 && + currentQueue.hasNextPage() && + !(dataStore.get(DisableLoadMoreWhenRepeatAllKey, false) && player.repeatMode == REPEAT_MODE_ALL) + ) { + scope.launch(SilentHandler) { + val mediaItems = + currentQueue.nextPage().filterExplicit(dataStore.get(HideExplicitKey, false)) + if (player.playbackState != STATE_IDLE) { + player.addMediaItems(mediaItems.drop(1)) + } + } + } + + // Guardar estado cuando cambia el item de medios + if (dataStore.get(PersistentQueueKey, true)) { + scope.launch { + delay(500) // Pequeño delay para asegurar que el estado esté estable + saveQueueToDisk() + } + } + } + + override fun onPlaybackStateChanged( + @Player.State playbackState: Int, + ) { + // Guardar estado cuando cambia el estado de reproducción + if (dataStore.get(PersistentQueueKey, true) && playbackState != Player.STATE_BUFFERING) { + scope.launch { + delay(500) + saveQueueToDisk() + } + } + + // Cuando termina la reproducción, ocultar notificación si la cola está vacía + if (playbackState == Player.STATE_ENDED) { + scope.launch { + delay(1000) + if (!player.isPlaying && player.mediaItemCount == 0) { + // Limpiar metadata para forzar actualización de notificación + currentMediaMetadata.value = null + } + } + } + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + if (playWhenReady) { + setupLoudnessEnhancer() + } + + // Actualizar notificación cuando cambia el estado de reproducción + scope.launch { + delay(300) + updateNotification() + } + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun onEvents( + player: Player, + events: Player.Events, + ) { + if (events.containsAny( + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAY_WHEN_READY_CHANGED + ) + ) { + val isBufferingOrReady = + player.playbackState == Player.STATE_BUFFERING || player.playbackState == Player.STATE_READY + if (isBufferingOrReady && player.playWhenReady) { + val focusGranted = requestAudioFocus() + if (focusGranted) { + openAudioEffectSession() + } + } else { + closeAudioEffectSession() + // Abandonar foco de audio cuando no está reproduciendo + if (!player.playWhenReady || player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) { + abandonAudioFocus() + } + } + } + + if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { + currentMediaMetadata.value = player.currentMetadata + // Forzar actualización de notificación para asegurar que la imagen se cargue + scope.launch { + delay(200) + updateNotification() + } + } + + // Actualización de Discord RPC + if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) { + if (player.isPlaying) { + currentSong.value?.let { song -> + scope.launch { + discordRpc?.updateSong(song, player.currentPosition, player.playbackParameters.speed, dataStore.get(DiscordUseDetailsKey, false)) + } + } + } + // Send empty activity to the Discord RPC if the player is not playing + else if (!events.containsAny(Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_MEDIA_ITEM_TRANSITION)){ + scope.launch { + discordRpc?.stopActivity() + } + } + } + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + updateNotification() + if (shuffleModeEnabled) { + // Si la cola está vacía, no mezclar + if (player.mediaItemCount == 0) return + + // Siempre poner el item que se está reproduciendo primero + val shuffledIndices = IntArray(player.mediaItemCount) { it } + shuffledIndices.shuffle() + shuffledIndices[shuffledIndices.indexOf(player.currentMediaItemIndex)] = + shuffledIndices[0] + shuffledIndices[0] = player.currentMediaItemIndex + player.setShuffleOrder(DefaultShuffleOrder(shuffledIndices, System.currentTimeMillis())) + } + + // Guardar estado cuando cambia el modo aleatorio + if (dataStore.get(PersistentQueueKey, true)) { + scope.launch { + delay(300) + saveQueueToDisk() + } + } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + updateNotification() + scope.launch { + dataStore.edit { settings -> + settings[RepeatModeKey] = repeatMode + } + } + + // Guardar estado cuando cambia el modo de repetición + if (dataStore.get(PersistentQueueKey, true)) { + scope.launch { + delay(300) + saveQueueToDisk() + } + } + } + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + + Log.e(TAG, "Player error: ${error.errorCodeName}, message: ${error.message}", error) + + val isConnectionError = (error.cause?.cause is PlaybackException) && + (error.cause?.cause as PlaybackException).errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED + + if (!isNetworkConnected.value || isConnectionError) { + waitOnNetworkError() + return + } + + if (dataStore.get(AutoSkipNextOnErrorKey, false)) { + skipOnError() + } else { + stopOnError() + } + } + + private fun createCacheDataSource(): CacheDataSource.Factory = + CacheDataSource + .Factory() + .setCache(downloadCache) + .setUpstreamDataSourceFactory( + CacheDataSource + .Factory() + .setCache(playerCache) + .setUpstreamDataSourceFactory( + DefaultDataSource.Factory( + this, + OkHttpDataSource.Factory( + OkHttpClient + .Builder() + .proxy(YouTube.proxy) + .build(), + ), + ), + ), + ).setCacheWriteDataSinkFactory(null) + .setFlags(FLAG_IGNORE_CACHE_ON_ERROR) + + private fun createDataSourceFactory(): DataSource.Factory { + val songUrlCache = HashMap>() + return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> + if (dataSpec.uri.scheme == "content" || dataSpec.uri.scheme == "file") { + return@Factory dataSpec + } + + val mediaId = dataSpec.key ?: error("No media id") + + if (downloadCache.isCached( + mediaId, + dataSpec.position, + if (dataSpec.length >= 0) dataSpec.length else 1 + ) || + playerCache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH) + ) { + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec + } + + songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let { + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec.withUri(it.first.toUri()) + } + + // Intentar YouTube primero (fuente principal) + val ytLogTag = "YouTube" + try { + val playbackData = runBlocking(Dispatchers.IO) { + YTPlayerUtils.playerResponseForPlayback( + mediaId, + audioQuality = audioQuality, + connectivityManager = connectivityManager, + ) + }.getOrElse { throwable -> + when (throwable) { + is PlaybackException -> throw throwable + + is ConnectException, is UnknownHostException -> { + throw PlaybackException( + getString(R.string.error_no_internet), + throwable, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED + ) + } + + is SocketTimeoutException -> { + throw PlaybackException( + getString(R.string.error_timeout), + throwable, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT + ) + } + + else -> throw PlaybackException( + getString(R.string.error_unknown), + throwable, + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + } + } + + val format = playbackData.format + + database.query { + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playbackData.audioConfig?.loudnessDb, + playbackUrl = playbackData.streamUrl + ) + ) + } + scope.launch(Dispatchers.IO) { recoverSong(mediaId, playbackData) } + + val streamUrl = playbackData.streamUrl + + songUrlCache[mediaId] = + streamUrl to System.currentTimeMillis() + (playbackData.streamExpiresInSeconds * 1000L) + return@Factory dataSpec.withUri(streamUrl.toUri()) + .subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) + } catch (e: Exception) { + Timber.tag(ytLogTag).e(e, "YouTube playback error, trying JossRed as fallback") + + // Verificar si la fuente alternativa está habilitada + val useAlternativeSource = runBlocking { + dataStore.data.map { preferences -> + val JossRedMultimedia = booleanPreferencesKey("JossRedMultimedia") + preferences[JossRedMultimedia] ?: false + }.first() + } + + // Si la fuente alternativa está deshabilitada, relanzar la excepción + if (!useAlternativeSource) { + throw e + } + + // Fuente alternativa: JossRed (fallback) + val JRlogTag = "JossRed" + try { + val alternativeUrl = runCatching { + runBlocking(Dispatchers.IO) { + withTimeout(5000) { + JossRedClient.getStreamingUrl(mediaId) + } + } + }.getOrNull() + + if (alternativeUrl != null) { + // Verificar accesibilidad de URL con una solicitud HEAD + val client = OkHttpClient.Builder() + .connectTimeout(2, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.SECONDS) + .build() + + val request = Request.Builder() + .url(alternativeUrl) + .head() + .build() + + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + Timber.tag(JRlogTag) + .i("Using JossRed URL as fallback: $alternativeUrl") + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec.withUri(alternativeUrl.toUri()) + } else { + Timber.tag(JRlogTag) + .w("JossRed URL unreachable (HTTP ${response.code}), throwing original error") + throw e + } + } catch (jrException: Exception) { + Timber.tag(JRlogTag).e( + jrException, + "Error verifying JossRed URL, throwing original error" + ) + throw e + } + } else { + throw e + } + } catch (jrException: Exception) { + when (jrException) { + is JossRedClient.JossRedException -> { + Timber.tag(JRlogTag) + .w("JossRed error: ${jrException.message}, throwing original error") + } + + is TimeoutCancellationException -> { + Timber.tag(JRlogTag).w("JossRed timeout, throwing original error") + } + + else -> { + Timber.tag(JRlogTag) + .e(jrException, "JossRed error, throwing original error") + } + } + throw e + } + } + } + } + + private fun createMediaSourceFactory() = + DefaultMediaSourceFactory(createDataSourceFactory()) + + private fun createRenderersFactory() = + object : DefaultRenderersFactory(this) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ) = DefaultAudioSink + .Builder(this@MusicService) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioProcessorChain( + DefaultAudioSink.DefaultAudioProcessorChain( + emptyArray(), + SilenceSkippingAudioProcessor(2_000_000, 20_000, 256), + SonicAudioProcessor(), + ), + ).build() + } + + override fun onPlaybackStatsReady( + eventTime: AnalyticsListener.EventTime, + playbackStats: PlaybackStats, + ) { + val mediaItem = + eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem + + if (playbackStats.totalPlayTimeMs >= ( + dataStore[HistoryDuration]?.times(1000f) + ?: 30000f + ) && + !dataStore.get(PauseListenHistoryKey, false) + ) { + database.query { + incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) + try { + insert( + Event( + songId = mediaItem.mediaId, + timestamp = LocalDateTime.now(), + playTime = playbackStats.totalPlayTimeMs, + ), + ) + } catch (_: SQLException) { + } + } + val PauseRemoteListenHistoryKey = booleanPreferencesKey("pauseRemoteListenHistory") + if (!dataStore.get(PauseRemoteListenHistoryKey, false)) { + CoroutineScope(Dispatchers.IO).launch { + val playbackUrl = database.format(mediaItem.mediaId).first()?.playbackUrl + ?: YTPlayerUtils.playerResponseForMetadata(mediaItem.mediaId, null) + .getOrNull()?.playbackTracking?.videostatsPlaybackUrl?.baseUrl + playbackUrl?.let { + YouTube.registerPlayback(null, playbackUrl) + .onFailure { + reportException(it) + } + } + } + } + } + } + + private fun saveQueueToDisk() { + if (player.playbackState == STATE_IDLE && player.mediaItemCount == 0) { + filesDir.resolve(PERSISTENT_AUTOMIX_FILE).delete() + filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() + filesDir.resolve(PERSISTENT_PLAYER_STATE_FILE).delete() + return + } + + try { + val persistQueue = + PersistQueue( + title = queueTitle, + items = player.mediaItems.mapNotNull { it.metadata }, + mediaItemIndex = player.currentMediaItemIndex.coerceAtLeast(0), + position = if (player.currentPosition >= 0) player.currentPosition else 0, + ) + val persistAutomix = + PersistQueue( + title = "automix", + items = automixItems.value.mapNotNull { it.metadata }, + mediaItemIndex = 0, + position = 0, + ) + + // Guardar estado del reproductor + val playerState = PersistPlayerState( + repeatMode = player.repeatMode, + shuffleModeEnabled = player.shuffleModeEnabled, + volume = player.volume, + currentMediaItemIndex = player.currentMediaItemIndex.coerceAtLeast(0), + currentPosition = if (player.currentPosition >= 0) player.currentPosition else 0, + playWhenReady = player.playWhenReady, // Estado de reproducción (si está listo para reproducir) + playbackState = player.playbackState // Estado actual del reproductor + ) + + runCatching { + filesDir.resolve(PERSISTENT_QUEUE_FILE).outputStream().use { fos -> + ObjectOutputStream(fos).use { oos -> + oos.writeObject(persistQueue) + } + } + }.onFailure { + Log.e(TAG, "Error saving queue to disk", it) + reportException(it) + } + + runCatching { + filesDir.resolve(PERSISTENT_AUTOMIX_FILE).outputStream().use { fos -> + ObjectOutputStream(fos).use { oos -> + oos.writeObject(persistAutomix) + } + } + }.onFailure { + Log.e(TAG, "Error saving automix to disk", it) + reportException(it) + } + + runCatching { + filesDir.resolve(PERSISTENT_PLAYER_STATE_FILE).outputStream().use { fos -> + ObjectOutputStream(fos).use { oos -> + oos.writeObject(playerState) + } + } + }.onFailure { + Log.e(TAG, "Error saving player state to disk", it) + reportException(it) + } + } catch (e: Exception) { + Log.e(TAG, "Error in saveQueueToDisk", e) + reportException(e) + } + } + + @RequiresApi(Build.VERSION_CODES.O) + override fun onDestroy() { + if (dataStore.get(PersistentQueueKey, true)) { + saveQueueToDisk() + } + if (discordRpc?.isRpcRunning() == true) { + discordRpc?.closeRPC() + } + discordRpc = null + abandonAudioFocus() + releaseLoudnessEnhancer() + mediaSession.release() + player.removeListener(this) + player.removeListener(sleepTimer) + player.release() + super.onDestroy() + } + + override fun onBind(intent: Intent?) = super.onBind(intent) ?: binder + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + stopSelf() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession + + inner class MusicBinder : Binder() { + val service: MusicService + get() = this@MusicService + } + + companion object { + const val ROOT = "root" + const val SONG = "song" + const val ARTIST = "artist" + const val ALBUM = "album" + const val PLAYLIST = "playlist" + + const val CHANNEL_ID = "music_channel_01" + const val NOTIFICATION_ID = 888 + const val PERSISTENT_PLAYER_STATE_FILE = "persistent_player_state.data" + const val MAX_CONSECUTIVE_ERR = 5 + const val CHUNK_LENGTH = 512 * 1024L + const val PERSISTENT_QUEUE_FILE = "persistent_queue.data" + const val PERSISTENT_AUTOMIX_FILE = "persistent_automix.data" + + // Constants for audio normalization + private const val MAX_GAIN_MB = 800 // Maximum gain in millibels (8 dB) + private const val MIN_GAIN_MB = -800 // Minimum gain in millibels (-8 dB) + + private const val TAG = "MusicService" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/PlayerConnection.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/PlayerConnection.kt new file mode 100644 index 00000000..c1927cf0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/PlayerConnection.kt @@ -0,0 +1,682 @@ +package com.darkxvenom.airbeats.playback + +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM +import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM +import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.STATE_READY +import androidx.media3.common.Timeline +import com.darkxvenom.airbeats.MusicWidget.Companion.ACTION_STATE_CHANGED +import com.darkxvenom.airbeats.MusicWidget.Companion.ACTION_UPDATE_PROGRESS +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.extensions.currentMetadata +import com.darkxvenom.airbeats.extensions.getCurrentQueueIndex +import com.darkxvenom.airbeats.extensions.getQueueWindows +import com.darkxvenom.airbeats.extensions.metadata +import com.darkxvenom.airbeats.playback.MusicService.MusicBinder +import com.darkxvenom.airbeats.playback.queues.Queue +import com.darkxvenom.airbeats.utils.reportException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +@OptIn(ExperimentalCoroutinesApi::class) +class PlayerConnection( + private val context: Context, + binder: MusicBinder, + val database: MusicDatabase, + scope: CoroutineScope, +) : Player.Listener { + + companion object { + private const val TAG = "PlayerConnection" + private const val PROGRESS_UPDATE_INTERVAL = 1000L + private const val WIDGET_UPDATE_DEBOUNCE = 100L + + @Volatile + var instance: PlayerConnection? = null + private set + } + + val service = binder.service + val player = service.player + + // Estados básicos del reproductor + private val _playbackState = MutableStateFlow(player.playbackState) + val playbackState: StateFlow = _playbackState.asStateFlow() + + private val _playWhenReady = MutableStateFlow(player.playWhenReady) + val playWhenReady: StateFlow = _playWhenReady.asStateFlow() + + // Estado combinado de reproducción + val isPlaying = combine(playbackState, playWhenReady) { playbackState, playWhenReady -> + playWhenReady && (playbackState == STATE_READY || playbackState == Player.STATE_BUFFERING) + }.stateIn( + scope, + SharingStarted.Lazily, + player.playWhenReady && player.playbackState == STATE_READY + ) + + // Estados de conexión y salud del reproductor + private val _isConnected = MutableStateFlow(true) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.CONNECTED) + val connectionState: StateFlow = _connectionState.asStateFlow() + + // Metadatos y información de la canción actual + private val _mediaMetadata = MutableStateFlow(player.currentMetadata) + val mediaMetadata: StateFlow = + _mediaMetadata.asStateFlow() + + val currentSong = mediaMetadata.flatMapLatest { metadata -> + database.song(metadata?.id) + } + + val currentLyrics = mediaMetadata.flatMapLatest { mediaMetadata -> + database.lyrics(mediaMetadata?.id) + } + + val currentFormat = mediaMetadata.flatMapLatest { mediaMetadata -> + database.format(mediaMetadata?.id) + } + + // Estados de la cola de reproducción + private val _queueTitle = MutableStateFlow(service.queueTitle) + val queueTitle: StateFlow = _queueTitle.asStateFlow() + + private val _queueWindows = MutableStateFlow>(emptyList()) + val queueWindows: StateFlow> = _queueWindows.asStateFlow() + + private val _currentMediaItemIndex = MutableStateFlow(-1) + val currentMediaItemIndex: StateFlow = _currentMediaItemIndex.asStateFlow() + + private val _currentWindowIndex = MutableStateFlow(-1) + val currentWindowIndex: StateFlow = _currentWindowIndex.asStateFlow() + + // Estados de control + private val _shuffleModeEnabled = MutableStateFlow(false) + val shuffleModeEnabled: StateFlow = _shuffleModeEnabled.asStateFlow() + + private val _repeatMode = MutableStateFlow(REPEAT_MODE_OFF) + val repeatMode: StateFlow = _repeatMode.asStateFlow() + + // Estados de navegación + private val _canSkipPrevious = MutableStateFlow(true) + val canSkipPrevious: StateFlow = _canSkipPrevious.asStateFlow() + + private val _canSkipNext = MutableStateFlow(true) + val canSkipNext: StateFlow = _canSkipNext.asStateFlow() + + // Estados de progreso y posición + private val _currentPosition = MutableStateFlow(0L) + val currentPosition: StateFlow = _currentPosition.asStateFlow() + + private val _duration = MutableStateFlow(0L) + val duration: StateFlow = _duration.asStateFlow() + + private val _bufferedPosition = MutableStateFlow(0L) + val bufferedPosition: StateFlow = _bufferedPosition.asStateFlow() + + // Estados de audio y volumen + private val _volume = MutableStateFlow(1.0f) + val volume: StateFlow = _volume.asStateFlow() + + private val _isMuted = MutableStateFlow(false) + val isMuted: StateFlow = _isMuted.asStateFlow() + + // Estados de favoritos y valoraciones + private val _isLiked = MutableStateFlow(false) + val isLiked: StateFlow = _isLiked.asStateFlow() + + // Estados de error + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + // Control de actualizaciones + private val updateScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val progressUpdateHandler = Handler(Looper.getMainLooper()) + private var progressUpdateRunnable: Runnable? = null + private val isUpdatingProgress = AtomicBoolean(false) + private val widgetUpdateHandler = Handler(Looper.getMainLooper()) + private var pendingWidgetUpdate: Runnable? = null + + // Estados previos para detectar cambios + private var lastPlaybackState: Int = player.playbackState + private var lastPlayWhenReady: Boolean = player.playWhenReady + private var lastMediaItemIndex: Int = player.currentMediaItemIndex + private var lastPosition: Long = 0L + + init { + Log.d(TAG, "Initializing PlayerConnection") + + player.addListener(this) + initializeStates() + startProgressUpdates() + + instance = this + + // Listener adicional para actualizaciones del widget + player.addListener(object : Player.Listener { + override fun onEvents(player: Player, events: Player.Events) { + handlePlayerEvents(player, events) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + Log.d(TAG, "Playback state changed to: $playbackState") + updateConnectionState(playbackState) + } + }) + + Log.d(TAG, "PlayerConnection initialized successfully") + } + + private fun initializeStates() { + try { + _playbackState.value = player.playbackState + _playWhenReady.value = player.playWhenReady + _mediaMetadata.value = player.currentMetadata + _queueTitle.value = service.queueTitle + _queueWindows.value = player.getQueueWindows() + _currentWindowIndex.value = player.getCurrentQueueIndex() + _currentMediaItemIndex.value = player.currentMediaItemIndex + _shuffleModeEnabled.value = player.shuffleModeEnabled + _repeatMode.value = player.repeatMode + _currentPosition.value = player.currentPosition + _duration.value = player.duration + _bufferedPosition.value = player.bufferedPosition + _volume.value = player.volume + + // Inicializar estado de like + CoroutineScope(Dispatchers.IO).launch { + updateLikeStatusForCurrentSong() + } + + updateCanSkipPreviousAndNext() + Log.d(TAG, "States initialized") + } catch (e: Exception) { + Log.e(TAG, "Error initializing states", e) + reportException(e) + } + } + + + fun refreshLikeStatus() { + updateScope.launch(Dispatchers.IO) { + updateLikeStatusForCurrentSong() + } + + } + + private fun handlePlayerEvents(player: Player, events: Player.Events) { + var shouldUpdateWidget = false + + if (events.containsAny( + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAY_WHEN_READY_CHANGED + ) + ) { + shouldUpdateWidget = true + } + + if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { + shouldUpdateWidget = true + } + + if (events.contains(Player.EVENT_POSITION_DISCONTINUITY) || + events.contains(Player.EVENT_TIMELINE_CHANGED) + ) { + shouldUpdateWidget = true + } + + if (shouldUpdateWidget) { + scheduleWidgetUpdate() + } + } + + private fun updateConnectionState(playbackState: Int) { + val newConnectionState = when (playbackState) { + Player.STATE_IDLE -> ConnectionState.IDLE + Player.STATE_BUFFERING -> ConnectionState.BUFFERING + Player.STATE_READY -> ConnectionState.CONNECTED + Player.STATE_ENDED -> ConnectionState.ENDED + else -> ConnectionState.ERROR + } + + _connectionState.value = newConnectionState + _isConnected.value = newConnectionState == ConnectionState.CONNECTED || + newConnectionState == ConnectionState.BUFFERING + } + + private fun startProgressUpdates() { + stopProgressUpdates() + + progressUpdateRunnable = object : Runnable { + override fun run() { + if (isUpdatingProgress.compareAndSet(false, true)) { + try { + updateProgressStates() + + // Programar siguiente actualización + if (player.playWhenReady && player.playbackState == STATE_READY) { + progressUpdateHandler.postDelayed(this, PROGRESS_UPDATE_INTERVAL) + } else { + progressUpdateHandler.postDelayed(this, PROGRESS_UPDATE_INTERVAL * 2) + } + } finally { + isUpdatingProgress.set(false) + } + } + } + } + + progressUpdateRunnable?.let { + progressUpdateHandler.post(it) + } + } + + private fun stopProgressUpdates() { + progressUpdateRunnable?.let { runnable -> + progressUpdateHandler.removeCallbacks(runnable) + progressUpdateRunnable = null + } + } + + private fun updateProgressStates() { + try { + val currentPos = player.currentPosition + val totalDuration = player.duration + val buffered = player.bufferedPosition + + // Solo actualizar si hay cambios significativos + if (kotlin.math.abs(currentPos - lastPosition) > 500L || + _duration.value != totalDuration + ) { + + _currentPosition.value = currentPos + _duration.value = totalDuration + _bufferedPosition.value = buffered + + lastPosition = currentPos + + // Enviar broadcast para actualización de progreso + sendProgressUpdateBroadcast() + } + } catch (e: Exception) { + Log.e(TAG, "Error updating progress states", e) + } + } + + private fun scheduleWidgetUpdate() { + // Cancelar actualización pendiente + pendingWidgetUpdate?.let { widgetUpdateHandler.removeCallbacks(it) } + + // Programar nueva actualización con debounce + pendingWidgetUpdate = Runnable { + sendStateChangedBroadcast() + } + + widgetUpdateHandler.postDelayed(pendingWidgetUpdate!!, WIDGET_UPDATE_DEBOUNCE) + } + + + fun playQueue(queue: Queue) { + service.playQueue(queue) + } + + fun playNext(item: MediaItem) = playNext(listOf(item)) + + fun playNext(items: List) { + try { + Log.d(TAG, "Adding ${items.size} items to play next") + service.playNext(items) + } catch (e: Exception) { + Log.e(TAG, "Error adding items to play next", e) + reportException(e) + } + } + + fun addToQueue(item: MediaItem) = addToQueue(listOf(item)) + + fun addToQueue(items: List) { + try { + Log.d(TAG, "Adding ${items.size} items to queue") + service.addToQueue(items) + } catch (e: Exception) { + Log.e(TAG, "Error adding items to queue", e) + reportException(e) + } + } + + fun toggleLike() { + try { + Timber.tag(TAG).d("Toggling like for current track. Current state: ${_isLiked.value}") + + // Llamar al servicio para cambiar el estado en la base de datos + service.toggleLike() + + // Actualizar estado local + _isLiked.value = !_isLiked.value + + // Notificar cambios al widget + scheduleWidgetUpdate() + + Log.d(TAG, "Like toggled to: ${_isLiked.value}") + } catch (e: Exception) { + Log.e(TAG, "Error toggling like", e) + reportException(e) + } + } + + + fun isCurrentSongLiked(): Boolean { + return _isLiked.value + } + + fun seekToNext() { + try { + Log.d(TAG, "Seeking to next track") + if (player.hasNextMediaItem()) { + player.seekToNext() + player.prepare() + player.playWhenReady = true + } + } catch (e: Exception) { + Log.e(TAG, "Error seeking to next", e) + reportException(e) + } + } + + fun seekToPrevious() { + try { + Log.d(TAG, "Seeking to previous track") + if (player.hasPreviousMediaItem() || player.currentPosition > 3000) { + player.seekToPrevious() + player.prepare() + player.playWhenReady = true + } + } catch (e: Exception) { + Log.e(TAG, "Error seeking to previous", e) + reportException(e) + } + } + + fun togglePlayPause() { + try { + val newPlayWhenReady = !player.playWhenReady + Log.d(TAG, "Toggling play/pause to: $newPlayWhenReady") + player.playWhenReady = newPlayWhenReady + } catch (e: Exception) { + Log.e(TAG, "Error toggling play/pause", e) + reportException(e) + } + } + + fun toggleShuffle() { + try { + val newShuffleMode = !player.shuffleModeEnabled + Log.d(TAG, "Toggling shuffle to: $newShuffleMode") + player.shuffleModeEnabled = newShuffleMode + } catch (e: Exception) { + Log.e(TAG, "Error toggling shuffle", e) + reportException(e) + } + } + + fun toggleReplayMode() { + try { + val newRepeatMode = if (player.repeatMode == Player.REPEAT_MODE_ONE) { + REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE + } + Log.d(TAG, "Toggling repeat mode to: $newRepeatMode") + player.repeatMode = newRepeatMode + } catch (e: Exception) { + Log.e(TAG, "Error toggling repeat mode", e) + reportException(e) + } + } + + fun seekTo(positionMs: Long) { + try { + Log.d(TAG, "Seeking to position: ${positionMs}ms") + player.seekTo(positionMs.coerceIn(0, player.duration)) + } catch (e: Exception) { + Log.e(TAG, "Error seeking to position", e) + reportException(e) + } + } + + fun setVolume(volume: Float) { + try { + val clampedVolume = volume.coerceIn(0.0f, 1.0f) + Log.d(TAG, "Setting volume to: $clampedVolume") + player.volume = clampedVolume + _volume.value = clampedVolume + } catch (e: Exception) { + Log.e(TAG, "Error setting volume", e) + reportException(e) + } + } + + fun toggleMute() { + try { + val newMuteState = !_isMuted.value + Log.d(TAG, "Toggling mute to: $newMuteState") + + if (newMuteState) { + player.volume = 0.0f + } else { + player.volume = _volume.value + } + + _isMuted.value = newMuteState + } catch (e: Exception) { + Log.e(TAG, "Error toggling mute", e) + reportException(e) + } + } + + // Listeners del reproductor sobrescritos + override fun onPlaybackStateChanged(state: Int) { + Log.d(TAG, "Playback state changed: $lastPlaybackState -> $state") + _playbackState.value = state + _error.value = player.playerError + + if (lastPlaybackState != state) { + lastPlaybackState = state + scheduleWidgetUpdate() + } + } + + override fun onPlayWhenReadyChanged(newPlayWhenReady: Boolean, reason: Int) { + Log.d( + TAG, + "PlayWhenReady changed: $lastPlayWhenReady -> $newPlayWhenReady (reason: $reason)" + ) + _playWhenReady.value = newPlayWhenReady + + if (lastPlayWhenReady != newPlayWhenReady) { + lastPlayWhenReady = newPlayWhenReady + scheduleWidgetUpdate() + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + Timber.tag(TAG).d("Media item transition: ${mediaItem?.mediaId} (reason: $reason)") + _mediaMetadata.value = mediaItem?.metadata + _currentMediaItemIndex.value = player.currentMediaItemIndex + _currentWindowIndex.value = player.getCurrentQueueIndex() + + // Actualizar estado de like cuando cambia la canción + CoroutineScope(Dispatchers.IO).launch { + updateLikeStatusForCurrentSong() + } + + if (lastMediaItemIndex != player.currentMediaItemIndex) { + lastMediaItemIndex = player.currentMediaItemIndex + updateCanSkipPreviousAndNext() + scheduleWidgetUpdate() + } + } + + private suspend fun updateLikeStatusForCurrentSong() { + try { + val currentSongId = withContext(Dispatchers.Main) { player.currentMediaItem?.mediaId } + if (currentSongId != null) { + // Consultar la base de datos para obtener el estado actual del like + // Similar a como lo hace Player.kt con currentSong?.song?.liked + val songWithInfo = database.song(currentSongId).first() + _isLiked.value = songWithInfo?.song?.liked ?: false + Timber.tag(TAG).d("Like status updated for song $currentSongId: ${_isLiked.value}") + } else { + _isLiked.value = false + Timber.tag(TAG).d("No current song, setting like status to false") + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error updating like status for current song") + _isLiked.value = false + } + } + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + Log.d(TAG, "Timeline changed (reason: $reason)") + _queueWindows.value = player.getQueueWindows() + _queueTitle.value = service.queueTitle + _currentMediaItemIndex.value = player.currentMediaItemIndex + _currentWindowIndex.value = player.getCurrentQueueIndex() + updateCanSkipPreviousAndNext() + } + + override fun onShuffleModeEnabledChanged(enabled: Boolean) { + Log.d(TAG, "Shuffle mode changed: $enabled") + _shuffleModeEnabled.value = enabled + _queueWindows.value = player.getQueueWindows() + _currentWindowIndex.value = player.getCurrentQueueIndex() + updateCanSkipPreviousAndNext() + scheduleWidgetUpdate() + } + + override fun onRepeatModeChanged(mode: Int) { + Log.d(TAG, "Repeat mode changed: $mode") + _repeatMode.value = mode + updateCanSkipPreviousAndNext() + scheduleWidgetUpdate() + } + + override fun onPlayerErrorChanged(playbackError: PlaybackException?) { + Log.e(TAG, "Player error changed", playbackError) + if (playbackError != null) { + reportException(playbackError) + } + _error.value = playbackError + updateConnectionState(Player.STATE_IDLE) + } + + override fun onVolumeChanged(volume: Float) { + Log.d(TAG, "Volume changed: $volume") + _volume.value = volume + _isMuted.value = volume == 0.0f + } + + private fun updateCanSkipPreviousAndNext() { + try { + if (!player.currentTimeline.isEmpty) { + val window = player.currentTimeline.getWindow( + player.currentMediaItemIndex, + Timeline.Window() + ) + + val canPrevious = player.isCommandAvailable(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) || + !window.isLive || + player.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) || + player.currentPosition > 3000 + + val canNext = (window.isLive && window.isDynamic) || + player.isCommandAvailable(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + + _canSkipPrevious.value = canPrevious + _canSkipNext.value = canNext + } else { + _canSkipPrevious.value = false + _canSkipNext.value = false + } + } catch (e: Exception) { + Log.e(TAG, "Error updating skip states", e) + _canSkipPrevious.value = false + _canSkipNext.value = false + } + } + + private fun sendStateChangedBroadcast() { + try { + val intent = Intent(ACTION_STATE_CHANGED).apply { + setPackage(context.packageName) + } + context.sendBroadcast(intent) + } catch (e: Exception) { + Log.e(TAG, "Error sending state changed broadcast", e) + } + } + + private fun sendProgressUpdateBroadcast() { + try { + val intent = Intent(ACTION_UPDATE_PROGRESS).apply { + setPackage(context.packageName) + } + context.sendBroadcast(intent) + } catch (e: Exception) { + Log.e(TAG, "Error sending progress update broadcast", e) + } + } + + fun dispose() { + Log.d(TAG, "Disposing PlayerConnection") + + stopProgressUpdates() + + // Cancelar actualizaciones pendientes + pendingWidgetUpdate?.let { widgetUpdateHandler.removeCallbacks(it) } + + try { + player.removeListener(this) + } catch (e: Exception) { + Log.e(TAG, "Error removing player listener", e) + } + + instance = null + + Log.d(TAG, "PlayerConnection disposed") + } + + // Estados de conexión + enum class ConnectionState { + IDLE, + CONNECTING, + CONNECTED, + BUFFERING, + ENDED, + ERROR + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/SleepTimer.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/SleepTimer.kt new file mode 100644 index 00000000..58785fd0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/SleepTimer.kt @@ -0,0 +1,67 @@ +package com.darkxvenom.airbeats.playback + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.minutes + +class SleepTimer( + private val scope: CoroutineScope, + val player: Player, +) : Player.Listener { + private var sleepTimerJob: Job? = null + var triggerTime by mutableStateOf(-1L) + private set + var pauseWhenSongEnd by mutableStateOf(false) + private set + val isActive: Boolean + get() = triggerTime != -1L || pauseWhenSongEnd + + fun start(minute: Int) { + sleepTimerJob?.cancel() + sleepTimerJob = null + if (minute == -1) { + pauseWhenSongEnd = true + } else { + triggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds + sleepTimerJob = + scope.launch { + delay(minute.minutes) + player.pause() + triggerTime = -1L + } + } + } + + fun clear() { + sleepTimerJob?.cancel() + sleepTimerJob = null + pauseWhenSongEnd = false + triggerTime = -1L + } + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + if (pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } + + override fun onPlaybackStateChanged( + @Player.State playbackState: Int, + ) { + if (playbackState == Player.STATE_ENDED && pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/queues/EmptyQueue.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/EmptyQueue.kt new file mode 100644 index 00000000..fb8ad1d4 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/EmptyQueue.kt @@ -0,0 +1,14 @@ +package com.darkxvenom.airbeats.playback.queues + +import androidx.media3.common.MediaItem +import com.darkxvenom.airbeats.models.MediaMetadata + +object EmptyQueue : Queue { + override val preloadItem: MediaMetadata? = null + + override suspend fun getInitialStatus() = Queue.Status(null, emptyList(), -1) + + override fun hasNextPage() = false + + override suspend fun nextPage() = emptyList() +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/queues/ListQueue.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/ListQueue.kt new file mode 100644 index 00000000..1041f1fe --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/ListQueue.kt @@ -0,0 +1,19 @@ +package com.darkxvenom.airbeats.playback.queues + +import androidx.media3.common.MediaItem +import com.darkxvenom.airbeats.models.MediaMetadata + +class ListQueue( + val title: String? = null, + val items: List, + val startIndex: Int = 0, + val position: Long = 0L, +) : Queue { + override val preloadItem: MediaMetadata? = null + + override suspend fun getInitialStatus() = Queue.Status(title, items, startIndex, position) + + override fun hasNextPage(): Boolean = false + + override suspend fun nextPage() = throw UnsupportedOperationException() +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/queues/LocalAlbumRadio.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/LocalAlbumRadio.kt new file mode 100644 index 00000000..974ffbba --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/LocalAlbumRadio.kt @@ -0,0 +1,53 @@ +package com.darkxvenom.airbeats.playback.queues + +import androidx.media3.common.MediaItem +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.db.entities.AlbumWithSongs +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.models.MediaMetadata +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +class LocalAlbumRadio( + private val albumWithSongs: AlbumWithSongs, + private val startIndex: Int = 0, +) : Queue { + override val preloadItem: MediaMetadata? = null + + private lateinit var playlistId: String + private val endpoint: WatchEndpoint + get() = WatchEndpoint( + playlistId = playlistId, + params = "wAEB" + ) + + private var continuation: String? = null + private var firstTimeLoaded: Boolean = false + + override suspend fun getInitialStatus(): Queue.Status = withContext(IO) { + Queue.Status( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.map { it.toMediaItem() }, + mediaItemIndex = startIndex + ) + } + + override fun hasNextPage(): Boolean = !firstTimeLoaded || continuation != null + + override suspend fun nextPage(): List = withContext(IO) { + if (!firstTimeLoaded) { + playlistId = YouTube.album(albumWithSongs.album.id).getOrThrow().album.playlistId + val nextResult = YouTube.next(endpoint, continuation).getOrThrow() + continuation = nextResult.continuation + firstTimeLoaded = true + return@withContext nextResult.items.subList( + albumWithSongs.songs.size, + nextResult.items.size + ).map { it.toMediaItem() } + } + val nextResult = YouTube.next(endpoint, continuation).getOrThrow() + continuation = nextResult.continuation + nextResult.items.map { it.toMediaItem() } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/queues/Queue.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/Queue.kt new file mode 100644 index 00000000..2d067746 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/Queue.kt @@ -0,0 +1,40 @@ +package com.darkxvenom.airbeats.playback.queues + +import androidx.media3.common.MediaItem +import com.darkxvenom.airbeats.extensions.metadata +import com.darkxvenom.airbeats.models.MediaMetadata + +interface Queue { + val preloadItem: MediaMetadata? + + suspend fun getInitialStatus(): Status + + fun hasNextPage(): Boolean + + suspend fun nextPage(): List + + data class Status( + val title: String?, + val items: List, + val mediaItemIndex: Int, + val position: Long = 0L, + ) { + fun filterExplicit(enabled: Boolean = true) = + if (enabled) { + copy( + items = items.filterExplicit(), + ) + } else { + this + } + } +} + +fun List.filterExplicit(enabled: Boolean = true) = + if (enabled) { + filterNot { + it.metadata?.explicit == true + } + } else { + this + } diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/queues/YouTubeAlbumRadio.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/YouTubeAlbumRadio.kt new file mode 100644 index 00000000..a056c4f8 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/YouTubeAlbumRadio.kt @@ -0,0 +1,48 @@ +package com.darkxvenom.airbeats.playback.queues + +import androidx.media3.common.MediaItem +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.models.MediaMetadata +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +class YouTubeAlbumRadio( + private var playlistId: String, +) : Queue { + override val preloadItem: MediaMetadata? = null + + private val endpoint: WatchEndpoint + get() = WatchEndpoint( + playlistId = playlistId, + params = "wAEB" + ) + + private var albumSongCount = 0 + private var continuation: String? = null + private var firstTimeLoaded: Boolean = false + + override suspend fun getInitialStatus(): Queue.Status = withContext(IO) { + val albumSongs = YouTube.albumSongs(playlistId).getOrThrow() + albumSongCount = albumSongs.size + Queue.Status( + title = albumSongs.first().album?.name.orEmpty(), + items = albumSongs.map { it.toMediaItem() }, + mediaItemIndex = 0 + ) + } + + override fun hasNextPage(): Boolean = !firstTimeLoaded || continuation != null + + override suspend fun nextPage(): List = withContext(IO) { + val nextResult = YouTube.next(endpoint, continuation).getOrThrow() + continuation = nextResult.continuation + if (!firstTimeLoaded) { + firstTimeLoaded = true + nextResult.items.subList(albumSongCount, nextResult.items.size).map { it.toMediaItem() } + } else { + nextResult.items.map { it.toMediaItem() } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/playback/queues/YouTubeQueue.kt b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/YouTubeQueue.kt new file mode 100644 index 00000000..ccaf5a3a --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/playback/queues/YouTubeQueue.kt @@ -0,0 +1,46 @@ +package com.darkxvenom.airbeats.playback.queues + +import androidx.media3.common.MediaItem +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.models.MediaMetadata +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +class YouTubeQueue( + private var endpoint: WatchEndpoint, + override val preloadItem: MediaMetadata? = null, +) : Queue { + private var continuation: String? = null + + override suspend fun getInitialStatus(): Queue.Status { + val nextResult = + withContext(IO) { + YouTube.next(endpoint, continuation).getOrThrow() + } + endpoint = nextResult.endpoint + continuation = nextResult.continuation + return Queue.Status( + title = nextResult.title, + items = nextResult.items.map { it.toMediaItem() }, + mediaItemIndex = nextResult.currentIndex ?: 0, + ) + } + + override fun hasNextPage(): Boolean = continuation != null + + override suspend fun nextPage(): List { + val nextResult = + withContext(IO) { + YouTube.next(endpoint, continuation).getOrThrow() + } + endpoint = nextResult.endpoint + continuation = nextResult.continuation + return nextResult.items.map { it.toMediaItem() } + } + + companion object { + fun radio(song: MediaMetadata) = YouTubeQueue(WatchEndpoint(song.id), song) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/AutoResizeText.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/AutoResizeText.kt new file mode 100644 index 00000000..aeeda636 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/AutoResizeText.kt @@ -0,0 +1,95 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +/** From https://stackoverflow.com/a/69780826 */ +@Composable +fun AutoResizeText( + text: String, + fontSizeRange: FontSizeRange, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontStyle: FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + style: TextStyle = LocalTextStyle.current, +) { + var fontSizeValue by remember { mutableFloatStateOf(fontSizeRange.max.value) } + var readyToDraw by remember { mutableStateOf(false) } + + Text( + text = text, + color = color, + maxLines = maxLines, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + style = style, + fontSize = fontSizeValue.sp, + onTextLayout = { + if (it.didOverflowHeight && !readyToDraw) { + // Did Overflow height, calculate next font size value + val nextFontSizeValue = fontSizeValue - fontSizeRange.step.value + if (nextFontSizeValue <= fontSizeRange.min.value) { + // Reached minimum, set minimum font size and it's readToDraw + fontSizeValue = fontSizeRange.min.value + readyToDraw = true + } else { + // Text doesn't fit yet and haven't reached minimum text range, keep decreasing + fontSizeValue = nextFontSizeValue + } + } else { + // Text fits before reaching the minimum, it's readyToDraw + readyToDraw = true + } + }, + modifier = modifier.drawWithContent { if (readyToDraw) drawContent() }, + ) +} + +data class FontSizeRange( + val min: TextUnit, + val max: TextUnit, + val step: TextUnit = DEFAULT_TEXT_STEP, +) { + init { + require(min < max) { "min should be less than max, $this" } + require(step.value > 0) { "step should be greater than 0, $this" } + } + + companion object { + private val DEFAULT_TEXT_STEP = 1.sp + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/AvatarDisplay.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/AvatarDisplay.kt new file mode 100644 index 00000000..762b9869 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/AvatarDisplay.kt @@ -0,0 +1,231 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.darkxvenom.airbeats.R + +/** + * Componente para mostrar el avatar del usuario en cualquier parte de la + * app + */ +@Composable +fun AvatarDisplay( + size: Dp = 40.dp, + showBorder: Boolean = false, + borderColor: Color = MaterialTheme.colorScheme.primary, + borderWidth: Dp = 2.dp, + modifier: Modifier = Modifier, + contentDescription: String? = null +) { + val context = LocalContext.current + val avatarManager = remember { AvatarPreferenceManager(context) } + val currentSelection by avatarManager.getAvatarSelection.collectAsState(initial = AvatarSelection.Default) + + val displayModifier = if (showBorder) { + modifier + .size(size) + .clip(CircleShape) + .border(borderWidth, borderColor, CircleShape) + } else { + modifier + .size(size) + .clip(CircleShape) + } + + Box( + modifier = displayModifier, + contentAlignment = Alignment.Center + ) { + when (currentSelection) { + is AvatarSelection.Custom -> { + AsyncImage( + model = ImageRequest.Builder(context) + .data((currentSelection as AvatarSelection.Custom).uri.toUri()) + .crossfade(true) + .error(R.drawable.person) + .placeholder(R.drawable.person) + .build(), + contentDescription = contentDescription + ?: stringResource(id = R.string.custom_avatar), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + is AvatarSelection.DiceBear -> { + AsyncImage( + model = ImageRequest.Builder(context) + .data((currentSelection as AvatarSelection.DiceBear).url) + .crossfade(true) + .error(R.drawable.person) + .placeholder(R.drawable.person) + .build(), + contentDescription = contentDescription ?: "Avatar DiceBear", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + else -> { + DefaultAvatarIcon(contentDescription = contentDescription) + } + } + } +} + +/** Variante pequeña para usar en listas o elementos compactos */ +@Composable +fun SmallAvatarDisplay( + modifier: Modifier = Modifier, + contentDescription: String? = null +) { + AvatarDisplay( + size = 32.dp, + showBorder = false, + modifier = modifier, + contentDescription = contentDescription + ) +} + +/** Variante grande para perfiles o pantallas principales */ +@Composable +fun LargeAvatarDisplay( + modifier: Modifier = Modifier, + contentDescription: String? = null, + showBorder: Boolean = true +) { + AvatarDisplay( + size = 80.dp, + showBorder = showBorder, + borderWidth = 3.dp, + modifier = modifier, + contentDescription = contentDescription + ) +} + +/** Avatar con indicador de estado online/offline */ +@Composable +fun AvatarWithStatus( + isOnline: Boolean = true, + size: Dp = 40.dp, + modifier: Modifier = Modifier, + contentDescription: String? = null +) { + Box(modifier = modifier) { + AvatarDisplay( + size = size, + showBorder = true, + contentDescription = contentDescription + ) + + // Indicador de estado + if (isOnline) { + Box( + modifier = Modifier + .size(size * 0.3f) + .clip(CircleShape) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.surface, + shape = CircleShape + ) + .align(Alignment.BottomEnd), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + ) { + Icon( + painter = painterResource(R.drawable.person), + contentDescription = "Online", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } +} + +/** Icono de avatar por defecto */ +@Composable +private fun DefaultAvatarIcon(contentDescription: String? = null) { + Icon( + painter = painterResource(id = R.drawable.person), + contentDescription = contentDescription ?: stringResource(id = R.string.default_avatar), + modifier = Modifier.fillMaxSize(0.6f), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +/** + * Utilidad para obtener el avatar actual de manera síncrona (para casos + * especiales) + */ +class AvatarUtils { + companion object { + /** Obtiene la URI del avatar personalizado si está seleccionado */ + fun getCustomAvatarUri(selection: AvatarSelection): String? { + return when (selection) { + is AvatarSelection.Custom -> selection.uri + else -> null + } + } + + /** Obtiene la URL del avatar DiceBear si está seleccionado */ + fun getDiceBearAvatarUrl(selection: AvatarSelection): String? { + return when (selection) { + is AvatarSelection.DiceBear -> selection.url + else -> null + } + } + + /** Verifica si el avatar actual es personalizado */ + fun isCustomAvatar(selection: AvatarSelection): Boolean { + return selection is AvatarSelection.Custom + } + + /** Verifica si el avatar actual es DiceBear */ + fun isDiceBearAvatar(selection: AvatarSelection): Boolean { + return selection is AvatarSelection.DiceBear + } + + /** Verifica si el avatar actual es por defecto */ + fun isDefaultAvatar(selection: AvatarSelection): Boolean { + return selection is AvatarSelection.Default + } + + /** Obtiene la URL o URI del avatar actual */ + fun getAvatarSource(selection: AvatarSelection): String? { + return when (selection) { + is AvatarSelection.Custom -> selection.uri + is AvatarSelection.DiceBear -> selection.url + else -> null + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/BigSeekBar.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/BigSeekBar.kt new file mode 100644 index 00000000..7930f519 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/BigSeekBar.kt @@ -0,0 +1,59 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.unit.dp + +@Composable +fun BigSeekBar( + progressProvider: () -> Float, + onProgressChange: (Float) -> Unit, + modifier: Modifier = Modifier, + background: Color = MaterialTheme.colorScheme.surfaceTint.copy(alpha = 0.13f), + color: Color = MaterialTheme.colorScheme.primary, +) { + var width by remember { + mutableFloatStateOf(0f) + } + + Canvas( + modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(16.dp)) + .onPlaced { + width = it.size.width.toFloat() + } + .pointerInput(progressProvider) { + detectHorizontalDragGestures { _, dragAmount -> + onProgressChange( + (progressProvider() + dragAmount * 1.2f / width).coerceIn( + 0f, + 1f + ) + ) + } + }, + ) { + drawRect(color = background) + + drawRect( + color = color, + size = size.copy(width = size.width * progressProvider()), + ) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheet.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheet.kt new file mode 100644 index 00000000..bce355ba --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheet.kt @@ -0,0 +1,348 @@ +package com.darkxvenom.airbeats.ui.component + +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.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.spring +import androidx.compose.foundation.clickable +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.BoxWithConstraints +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.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.constants.NavigationBarAnimationSpec +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.pow + +/** + * Bottom Sheet + * Modified from [ViMusic](https://github.com/vfsfitvnm/ViMusic) + */ +@Composable +fun BottomSheet( + state: BottomSheetState, + modifier: Modifier = Modifier, + background: @Composable (BoxScope.() -> Unit) = { }, + onDismiss: (() -> Unit)? = null, + collapsedContent: @Composable BoxScope.() -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .graphicsLayer { + // background fades during about 10%-61% progress + alpha = (1.4f * (state.progress.coerceAtLeast(0.1f) - 0.1f).pow(0.5f)).coerceIn(0f, 1f) + } + .fillMaxSize(), + content = background + ) + Box( + modifier = modifier + .fillMaxSize() + .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) + } + ) + } + .clip( + RoundedCornerShape( + topStart = if (!state.isExpanded) 16.dp else 0.dp, + topEnd = if (!state.isExpanded) 16.dp else 0.dp + ) + ) + ) { + if (!state.isCollapsed && !state.isDismissed) { + BackHandler(onBack = state::collapseSoft) + } + + if (!state.isCollapsed) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = ((state.progress - 0.25f) * 4).coerceIn(0f, 1f) + }, + content = content + ) + } + + if (!state.isExpanded && (onDismiss == null || !state.isDismissed)) { + Box( + modifier = Modifier + .graphicsLayer { + alpha = 1f - (state.progress * 4).coerceAtMost(1f) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + 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(spring(stiffness = Spring.StiffnessMediumLow)) + } + + fun expandSoft() { + expand(spring(stiffness = Spring.StiffnessMediumLow)) + } + + 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.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): 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 { + mutableIntStateOf(initialAnchor) + } + val animatable = remember { + Animatable(0.dp, Dp.VectorConverter) + } + + return remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) { + val initialValue = when (previousAnchor) { + expandedAnchor -> expandedBound + collapsedAnchor -> collapsedBound + dismissedAnchor -> dismissedBound + else -> error("Unknown BottomSheet anchor") + } + + animatable.updateBounds(dismissedBound.coerceAtMost(expandedBound), expandedBound) + coroutineScope.launch { + animatable.animateTo(initialValue, NavigationBarAnimationSpec) + } + + BottomSheetState( + draggableState = DraggableState { delta -> + coroutineScope.launch { + animatable.snapTo(animatable.value - with(density) { delta.toDp() }) + } + }, + onAnchorChanged = { previousAnchor = it }, + coroutineScope = coroutineScope, + animatable = animatable, + collapsedBound = collapsedBound + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheetMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheetMenu.kt new file mode 100644 index 00000000..c9b79999 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheetMenu.kt @@ -0,0 +1,86 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp + +val LocalMenuState = compositionLocalOf { MenuState() } + +@Stable +class MenuState( + isVisible: Boolean = false, + content: @Composable ColumnScope.() -> Unit = {}, +) { + var isVisible by mutableStateOf(isVisible) + var content by mutableStateOf(content) + + @OptIn(ExperimentalMaterial3Api::class) + fun show(content: @Composable ColumnScope.() -> Unit) { + isVisible = true + this.content = content + } + + @OptIn(ExperimentalMaterial3Api::class) + fun dismiss() { + isVisible = false + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomSheetMenu( + modifier: Modifier = Modifier, + state: MenuState, + background: Color = MaterialTheme.colorScheme.surface, +) { + val focusManager = LocalFocusManager.current + + if (state.isVisible) { + ModalBottomSheet( + onDismissRequest = { + focusManager.clearFocus() + state.isVisible = false + }, + containerColor = background, + contentColor = MaterialTheme.colorScheme.onSurface, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .size(width = 40.dp, height = 4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + ) + }, + modifier = modifier.fillMaxHeight() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + state.content(this) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheetPage.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheetPage.kt new file mode 100644 index 00000000..1a1e1dca --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/BottomSheetPage.kt @@ -0,0 +1,117 @@ +package com.darkxvenom.airbeats.ui.component + +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.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +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.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.ui.utils.top + +val LocalBottomSheetPageState = compositionLocalOf { BottomSheetPageState() } + +@Stable +class BottomSheetPageState( + isVisible: Boolean = false, + content: @Composable ColumnScope.() -> Unit = {}, +) { + var isVisible by mutableStateOf(isVisible) + var content by mutableStateOf(content) + + fun show(content: @Composable ColumnScope.() -> Unit) { + isVisible = true + this.content = content + } + + fun dismiss() { + isVisible = false + } +} + +@Composable +fun BottomSheetPage( + modifier: Modifier = Modifier, + state: BottomSheetPageState, + background: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation), +) { + val focusManager = LocalFocusManager.current + + AnimatedVisibility( + visible = state.isVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + BackHandler { + state.dismiss() + } + + Spacer( + modifier = + Modifier + .pointerInput(Unit) { + detectTapGestures { + state.dismiss() + } + } + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)) + .fillMaxSize(), + ) + + + } + + AnimatedVisibility( + visible = state.isVisible, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = modifier, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(top = 48.dp) + .clip(ShapeDefaults.Large.top()) + .background(background), + ) { + state.content(this) + } + } + + LaunchedEffect(state.isVisible) { + if (state.isVisible) { + focusManager.clearFocus() + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/ChangelogButton.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ChangelogButton.kt new file mode 100644 index 00000000..2e22fb03 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ChangelogButton.kt @@ -0,0 +1,1250 @@ +package com.darkxvenom.airbeats.ui.component + +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.darkxvenom.airbeats.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap + +// Data classes mejoradas +data class ChangelogState( + val releases: List = emptyList(), + val commits: List = emptyList(), + val isLoadingReleases: Boolean = true, + val isLoadingCommits: Boolean = true, + val releasesError: String? = null, + val commitsError: String? = null, + val lastUpdated: String? = null +) + +data class Release( + val tagName: String, + val name: String, + val body: String, + val publishedAt: String, + val isPrerelease: Boolean, + val htmlUrl: String, + val author: String? = null +) + +data class Commit( + val sha: String, + val message: String, + val author: String, + val date: String, + val htmlUrl: String, + val shortSha: String = sha.take(7) +) + +enum class ChangelogTab(val title: String) { + RELEASES("Releases"), + COMMITS("Commits") +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChangelogButton( + viewModel: ChangelogViewModel = viewModel() +) { + var showBottomSheet by remember { mutableStateOf(false) } + + SettingsCategoryItem( + title = { Text(stringResource(R.string.Changelog)) }, + icon = painterResource(R.drawable.schedule), + onClick = { showBottomSheet = true } + ) + + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + dragHandle = { + Surface( + modifier = Modifier + .padding(vertical = 12.dp) + .width(32.dp) + .height(4.dp), + shape = RoundedCornerShape(2.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) {} + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .verticalScroll(rememberScrollState()) + ) { + ChangelogScreen(viewModel) + Spacer(Modifier.height(32.dp)) + } + } + } +} + +@Composable +fun ChangelogScreen(viewModel: ChangelogViewModel = viewModel()) { + val uiState by viewModel.uiState.collectAsState() + var selectedTab by remember { mutableStateOf(ChangelogTab.RELEASES) } + + LaunchedEffect(Unit) { + viewModel.loadChangelog("d0x-dev", "AirBeats") + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header con título principal + Text( + text = stringResource(R.string.changelogs), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 8.dp) + ) + + // Tabs mejoradas con Material Design 3 + ImprovedTabRow( + selectedTab = selectedTab, + onTabSelected = { selectedTab = it } + ) + + // Contenido basado en la tab seleccionada + when (selectedTab) { + ChangelogTab.RELEASES -> { + ReleasesContent( + releases = uiState.releases, + isLoading = uiState.isLoadingReleases, + error = uiState.releasesError, + lastUpdated = uiState.lastUpdated, + onRetry = { viewModel.loadChangelog("d0x-dev", "AirBeats") } + ) + } + + ChangelogTab.COMMITS -> { + CommitsContent( + commits = uiState.commits, + isLoading = uiState.isLoadingCommits, + error = uiState.commitsError, + lastUpdated = uiState.lastUpdated, + onRetry = { viewModel.loadChangelog("d0x-dev", "AirBeats") } + ) + } + } + } +} + +@Composable +private fun ImprovedTabRow( + selectedTab: ChangelogTab, + onTabSelected: (ChangelogTab) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + ChangelogTab.values().forEach { tab -> + val isSelected = selectedTab == tab + Surface( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .clickable { onTabSelected(tab) }, + color = if (isSelected) + MaterialTheme.colorScheme.primary + else + Color.Transparent, + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = tab.title, + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + style = MaterialTheme.typography.labelLarge, + color = if (isSelected) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal + ) + } + } + } + } +} + +@Composable +private fun ReleasesContent( + releases: List, + isLoading: Boolean, + error: String?, + lastUpdated: String?, + onRetry: () -> Unit +) { + when { + isLoading -> LoadingIndicator("Loading releases...") + error != null -> ErrorContent(error, onRetry) + releases.isEmpty() -> EmptyContent("No releases available") + else -> SuccessReleasesContent(releases, lastUpdated) + } +} + +@Composable +private fun CommitsContent( + commits: List, + isLoading: Boolean, + error: String?, + lastUpdated: String?, + onRetry: () -> Unit +) { + when { + isLoading -> LoadingIndicator("Loading commits...") + error != null -> ErrorContent(error, onRetry) + commits.isEmpty() -> EmptyContent("No commits available") + else -> SuccessCommitsContent(commits, lastUpdated) + } +} + +@Composable +private fun LoadingIndicator(message: String) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ErrorContent(error: String, onRetry: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(R.drawable.schedule), // Cambiar por icono de error + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(24.dp) + ) + Text( + text = "Error al cargar", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Button( + onClick = onRetry, + modifier = Modifier.align(Alignment.End), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text("Reintentar") + } + } + } +} + +@Composable +private fun EmptyContent(message: String) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(R.drawable.schedule), // Cambiar por icono apropiado + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp) + ) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun SuccessReleasesContent( + releases: List, + lastUpdated: String? +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + LastUpdatedIndicator(lastUpdated) + releases.forEach { release -> + ReleaseCard(release = release) + } + } +} + +@Composable +private fun SuccessCommitsContent( + commits: List, + lastUpdated: String? +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + LastUpdatedIndicator(lastUpdated) + commits.forEach { commit -> + CommitCard(commit = commit) + } + } +} + +@Composable +private fun LastUpdatedIndicator(lastUpdated: String?) { + lastUpdated?.let { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(16.dp) + ) { + Text( + text = "Last updated: $it", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } +} + +@Composable +private fun CommitCard(commit: Commit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.tertiaryContainer + ) { + Text( + text = commit.shortSha, + style = MaterialTheme.typography.labelSmall.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + Text( + text = commit.author, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = commit.message.lines().first(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = formatDate(commit.date), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ReleaseCard(release: Release) { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + colors = CardDefaults.cardColors( + containerColor = if (release.isPrerelease) + MaterialTheme.colorScheme.tertiaryContainer + else + MaterialTheme.colorScheme.surfaceContainerLow + ), + elevation = CardDefaults.cardElevation(defaultElevation = if (expanded) 4.dp else 1.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = release.tagName, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + if (release.isPrerelease) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.tertiary + ) { + Text( + text = "Pre-release", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiary, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + } + if (release.name.isNotEmpty() && release.name != release.tagName) { + Text( + text = release.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp) + ) + } + release.author?.let { author -> + Text( + text = "by $author", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatDate(release.publishedAt), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Icon( + painter = painterResource( + if (expanded) R.drawable.schedule else R.drawable.schedule // Cambiar por iconos de expandir/contraer + ), + contentDescription = if (expanded) "Contraer" else "Expandir", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(20.dp) + .padding(top = 4.dp) + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn(animationSpec = tween(300)) + expandVertically( + animationSpec = tween(300) + ), + exit = fadeOut(animationSpec = tween(300)) + shrinkVertically( + animationSpec = tween(300) + ) + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + thickness = 1.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + AdvancedMarkdownText( + markdown = release.body, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} + +// [El resto de las funciones de markdown permanecen igual...] +@Composable +fun AdvancedMarkdownText( + markdown: String, + modifier: Modifier = Modifier +) { + val cleanedMarkdown = cleanMarkdown(markdown) + val lines = cleanedMarkdown.lines() + + var inCodeBlock by remember { mutableStateOf(false) } + var codeBlockContent by remember { mutableStateOf("") } + var codeBlockLanguage by remember { mutableStateOf("") } + var inList by remember { mutableStateOf(false) } + var listItems by remember { mutableStateOf(mutableListOf()) } + + Column(modifier = modifier) { + for ((index, line) in lines.withIndex()) { + val trimmedLine = line.trim() + + when { + trimmedLine.startsWith("```") -> { + if (inList) { + ListContainer(listItems.toList()) + listItems.clear() + inList = false + } + + if (inCodeBlock) { + CodeBlock( + code = codeBlockContent.trimEnd(), + language = codeBlockLanguage + ) + codeBlockContent = "" + codeBlockLanguage = "" + inCodeBlock = false + } else { + codeBlockLanguage = trimmedLine.substring(3).trim() + inCodeBlock = true + } + } + + inCodeBlock -> { + codeBlockContent += line + "\n" + } + + trimmedLine.matches(Regex("^#{1,6}\\s+.*")) -> { + if (inList) { + ListContainer(listItems.toList()) + listItems.clear() + inList = false + } + + val level = trimmedLine.takeWhile { it == '#' }.length + val text = trimmedLine.substring(level).trim() + HeaderText(text = text, level = level) + } + + trimmedLine.matches(Regex("^[-*+]\\s+.*")) -> { + val content = trimmedLine.substring(2).trim() + if (!inList) { + inList = true + listItems.clear() + } + listItems.add(content) + } + + trimmedLine.matches(Regex("^\\d+\\.\\s+.*")) -> { + val content = trimmedLine.substringAfter(". ").trim() + if (!inList) { + inList = true + listItems.clear() + } + listItems.add(content) + } + + trimmedLine.startsWith("> ") -> { + if (inList) { + ListContainer(listItems.toList()) + listItems.clear() + inList = false + } + BlockQuote(trimmedLine.substring(2)) + } + + trimmedLine.matches(Regex("^[-*_]{3,}$")) -> { + if (inList) { + ListContainer(listItems.toList()) + listItems.clear() + inList = false + } + HorizontalRule() + } + + trimmedLine.isEmpty() -> { + if (inList) { + ListContainer(listItems.toList()) + listItems.clear() + inList = false + } + Spacer(modifier = Modifier.height(8.dp)) + } + + else -> { + if (inList) { + ListContainer(listItems.toList()) + listItems.clear() + inList = false + } + FormattedText(trimmedLine) + } + } + } + + if (inList && listItems.isNotEmpty()) { + ListContainer(listItems.toList()) + } + } +} + +private fun cleanMarkdown(markdown: String): String { + var cleaned = markdown + + cleaned = cleaned.replace(Regex("<[^>]+>"), "") + cleaned = cleaned.replace(Regex("!\\[([^\\]]*)\\]\\([^)]*\\)"), "") + cleaned = cleaned.replace(Regex("\\[([^\\]]+)\\]\\([^)]*\\)")) { matchResult -> + matchResult.groupValues[1] + } + cleaned = cleaned.replace(Regex("\\[([^\\]]+)\\]\\[[^\\]]*\\]")) { matchResult -> + matchResult.groupValues[1] + } + cleaned = cleaned.replace(Regex("^\\[[^\\]]+\\]:.*$", RegexOption.MULTILINE), "") + + val htmlEntities = mapOf( + "&" to "&", + "<" to "<", + ">" to ">", + """ to "\"", + "'" to "'", + " " to " ", + "'" to "'", + "'" to "'", + "…" to "...", + "—" to "—", + "–" to "–" + ) + + for ((entity, replacement) in htmlEntities) { + cleaned = cleaned.replace(entity, replacement) + } + + cleaned = cleaned.replace(Regex("\n{3,}"), "\n\n") + return cleaned.trim() +} + +@Composable +private fun HeaderText(text: String, level: Int) { + val style = when (level) { + 1 -> MaterialTheme.typography.headlineLarge + 2 -> MaterialTheme.typography.headlineMedium + 3 -> MaterialTheme.typography.headlineSmall + 4 -> MaterialTheme.typography.titleLarge + else -> MaterialTheme.typography.titleMedium + } + + Text( + text = text, + style = style, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = (12 - level * 2).coerceAtLeast(4).dp) + ) +} + +@Composable +private fun ListContainer(items: List) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLowest + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items.forEach { item -> + UnorderedListItem(item) + } + } + } +} + +@Composable +private fun UnorderedListItem(content: String) { + Row( + modifier = Modifier.padding(vertical = 2.dp), + verticalAlignment = Alignment.Top + ) { + Surface( + modifier = Modifier + .size(6.dp) + .padding(top = 8.dp), + shape = RoundedCornerShape(3.dp), + color = MaterialTheme.colorScheme.primary + ) {} + Spacer(modifier = Modifier.width(12.dp)) + FormattedText( + text = content, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun BlockQuote(content: String) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row { + Box( + modifier = Modifier + .width(4.dp) + .height(40.dp) + .background(MaterialTheme.colorScheme.primary) + ) + FormattedText( + text = content, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium.copy( + fontStyle = FontStyle.Italic + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun CodeBlock(code: String, language: String) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column { + if (language.isNotEmpty()) { + Surface( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + ) { + Text( + text = language, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } + } + Text( + text = code.trimEnd(), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp) + ) + } + } +} + +@Composable +private fun HorizontalRule() { + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outline + ) +} + +@Composable +private fun FormattedText( + text: String, + modifier: Modifier = Modifier, + style: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.bodyMedium, + color: Color = MaterialTheme.colorScheme.onSurface +) { + val annotatedString = buildAnnotatedString { + parseMarkdownText(text, this) + } + + Text( + text = annotatedString, + style = style, + color = color, + modifier = modifier.padding(vertical = 2.dp) + ) +} + +private fun parseMarkdownText(text: String, builder: AnnotatedString.Builder) { + var currentIndex = 0 + val processedText = text.trim() + + val patterns = listOf( + Triple( + Regex("`([^`]+)`"), + { match: MatchResult -> + builder.withStyle( + SpanStyle( + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + ) + ) { + append(match.groupValues[1]) + } + }, + 1 + ), + Triple( + Regex("\\*\\*([^*]+)\\*\\*"), + { match: MatchResult -> + builder.withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(match.groupValues[1]) + } + }, + 2 + ), + Triple( + Regex("__([^_]+)__"), + { match: MatchResult -> + builder.withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(match.groupValues[1]) + } + }, + 2 + ), + Triple( + Regex("(? + builder.withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(match.groupValues[1]) + } + }, + 3 + ), + Triple( + Regex("(? + builder.withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(match.groupValues[1]) + } + }, + 3 + ), + Triple( + Regex("~~([^~]+)~~"), + { match: MatchResult -> + builder.withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + append(match.groupValues[1]) + } + }, + 4 + ) + ) + + val allMatches = patterns.flatMap { (pattern, handler, priority) -> + pattern.findAll(processedText).map { match -> + Triple(match, handler, priority) + } + }.sortedWith(compareBy({ it.first.range.first }, { it.third })) + + val processedRanges = mutableListOf() + + for ((match, handler, _) in allMatches) { + val range = match.range + val overlaps = processedRanges.any { it.intersect(range).isNotEmpty() } + + if (!overlaps) { + if (range.first > currentIndex) { + builder.append(processedText.substring(currentIndex, range.first)) + } + handler(match) + currentIndex = range.last + 1 + processedRanges.add(range) + } + } + + if (currentIndex < processedText.length) { + builder.append(processedText.substring(currentIndex)) + } + + if (builder.length == 0) { + builder.append(processedText) + } +} + +private fun formatDate(dateString: String): String { + return try { + val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()) + val outputFormat = SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) + val date = inputFormat.parse(dateString) + outputFormat.format(date ?: Date()) + } catch (e: Exception) { + try { + // Formato alternativo para commits + val inputFormat2 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.getDefault()) + val outputFormat = SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) + val date = inputFormat2.parse(dateString) + outputFormat.format(date ?: Date()) + } catch (e2: Exception) { + dateString + } + } +} + +// ViewModel mejorado con funcionalidad de commits +class ChangelogViewModel : ViewModel() { + private val _uiState = MutableStateFlow(ChangelogState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val cache = ConcurrentHashMap>() + private val cacheTimeMs = 30 * 60 * 1000 // 30 minutos + + fun loadChangelog(repoOwner: String, repoName: String) { + viewModelScope.launch { + // Cargar releases y commits en paralelo + launch { loadReleases(repoOwner, repoName) } + launch { loadCommits(repoOwner, repoName) } + } + } + + private suspend fun loadReleases(repoOwner: String, repoName: String) { + _uiState.update { it.copy(isLoadingReleases = true, releasesError = null) } + + try { + val cacheKey = "releases_$repoOwner/$repoName" + val cachedData = getCachedData>(cacheKey) + + if (cachedData != null) { + _uiState.update { + it.copy( + releases = cachedData, + isLoadingReleases = false, + lastUpdated = getCurrentTimestamp() + ) + } + return + } + + val releases = fetchReleases(repoOwner, repoName) + cacheData(cacheKey, releases) + + _uiState.update { + it.copy( + releases = releases, + isLoadingReleases = false, + lastUpdated = getCurrentTimestamp() + ) + } + } catch (e: Exception) { + Log.e("ChangelogViewModel", "Error cargando releases", e) + _uiState.update { + it.copy( + isLoadingReleases = false, + releasesError = "Error al cargar releases: ${e.message}" + ) + } + } + } + + private suspend fun loadCommits(repoOwner: String, repoName: String) { + _uiState.update { it.copy(isLoadingCommits = true, commitsError = null) } + + try { + val cacheKey = "commits_$repoOwner/$repoName" + val cachedData = getCachedData>(cacheKey) + + if (cachedData != null) { + _uiState.update { + it.copy( + commits = cachedData, + isLoadingCommits = false, + lastUpdated = getCurrentTimestamp() + ) + } + return + } + + val commits = fetchCommits(repoOwner, repoName) + cacheData(cacheKey, commits) + + _uiState.update { + it.copy( + commits = commits, + isLoadingCommits = false, + lastUpdated = getCurrentTimestamp() + ) + } + } catch (e: Exception) { + Log.e("ChangelogViewModel", "Error cargando commits", e) + _uiState.update { + it.copy( + isLoadingCommits = false, + commitsError = "Error al cargar commits: ${e.message}" + ) + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun getCachedData(key: String): T? { + val cached = cache[key] ?: return null + val (data, timestamp) = cached + + if (System.currentTimeMillis() - timestamp > cacheTimeMs) { + cache.remove(key) + return null + } + + return data as? T + } + + private fun cacheData(key: String, data: Any) { + cache[key] = data to System.currentTimeMillis() + } + + private suspend fun fetchReleases(owner: String, repo: String): List = + withContext(Dispatchers.IO) { + repeat(3) { attempt -> + try { + val url = URL("https://api.github.com/repos/$owner/$repo/releases") + val connection = url.openConnection() as HttpURLConnection + connection.connectTimeout = 15000 + connection.readTimeout = 15000 + connection.setRequestProperty("Accept", "application/vnd.github.v3+json") + connection.setRequestProperty("User-Agent", "AirBeats-App") + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + if (attempt == 2) throw IOException("Error HTTP: ${connection.responseCode}") + delay(1000) + return@repeat + } + + val response = connection.inputStream.bufferedReader().use { it.readText() } + val jsonArray = JSONArray(response) + val releases = mutableListOf() + + for (i in 0 until jsonArray.length().coerceAtMost(15)) { + val releaseJson = jsonArray.getJSONObject(i) + val author = releaseJson.optJSONObject("author")?.optString("login") + + releases.add( + Release( + tagName = releaseJson.optString("tag_name", ""), + name = releaseJson.optString("name", ""), + body = releaseJson.optString("body", ""), + publishedAt = releaseJson.optString("published_at", ""), + isPrerelease = releaseJson.optBoolean("prerelease", false), + htmlUrl = releaseJson.optString("html_url", ""), + author = author + ) + ) + } + + return@withContext releases + } catch (e: Exception) { + if (attempt == 2) throw e + delay(1000) + } + } + + throw IOException("No se pudo obtener la información después de los reintentos") + } + + private suspend fun fetchCommits(owner: String, repo: String): List = + withContext(Dispatchers.IO) { + repeat(3) { attempt -> + try { + val url = URL("https://api.github.com/repos/$owner/$repo/commits?per_page=20") + val connection = url.openConnection() as HttpURLConnection + connection.connectTimeout = 15000 + connection.readTimeout = 15000 + connection.setRequestProperty("Accept", "application/vnd.github.v3+json") + connection.setRequestProperty("User-Agent", "AirBeats-App") + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + if (attempt == 2) throw IOException("Error HTTP: ${connection.responseCode}") + delay(1000) + return@repeat + } + + val response = connection.inputStream.bufferedReader().use { it.readText() } + val jsonArray = JSONArray(response) + val commits = mutableListOf() + + for (i in 0 until jsonArray.length().coerceAtMost(20)) { + val commitJson = jsonArray.getJSONObject(i) + val commitData = commitJson.getJSONObject("commit") + val author = commitData.getJSONObject("author") + + commits.add( + Commit( + sha = commitJson.optString("sha", ""), + message = commitData.optString("message", ""), + author = author.optString("name", "Unknown"), + date = author.optString("date", ""), + htmlUrl = commitJson.optString("html_url", "") + ) + ) + } + + return@withContext commits + } catch (e: Exception) { + if (attempt == 2) throw e + delay(1000) + } + } + + throw IOException("No se pudo obtener los commits después de los reintentos") + } + + private fun getCurrentTimestamp(): String { + val formatter = SimpleDateFormat("HH:mm", Locale.getDefault()) + return formatter.format(Date()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/ChipsRow.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ChipsRow.kt new file mode 100644 index 00000000..6a3787c8 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ChipsRow.kt @@ -0,0 +1,195 @@ +package com.darkxvenom.airbeats.ui.component + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +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.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.horizontalScroll +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +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.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.ui.screens.OptionStats + +@Composable +fun ChipsRow( + chips: List>, + currentValue: E, + onValueUpdate: (E) -> Unit, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) { + Spacer(Modifier.width(12.dp)) + + chips.forEach { (value, label) -> + FilterChip( + label = { Text(label) }, + selected = currentValue == value, + colors = FilterChipDefaults.filterChipColors( + containerColor = containerColor, + ), + onClick = { onValueUpdate(value) }, + shape = RoundedCornerShape(16.dp), + border = null + ) + + Spacer(Modifier.width(8.dp)) + } + } +} + +@SuppressLint("UnusedContentLambdaTargetStateParameter") +@Composable +fun ChoiceChipsRow( + chips: List>, + options: List>, + selectedOption: OptionStats, + onSelectionChange: (OptionStats) -> Unit, + currentValue: Int, + onValueUpdate: (Int) -> Unit, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, +) { + var expandIconDegree by remember { mutableFloatStateOf(0f) } + val rotationAnimation by animateFloatAsState( + targetValue = expandIconDegree, + animationSpec = tween(durationMillis = 400), + label = "", + ) + + Row( + modifier = + modifier + .fillMaxWidth() + .padding(start = 12.dp), + ) { + var expanded by remember { mutableStateOf(false) } + + Column { + AssistChip( + onClick = { + expanded = !expanded + expandIconDegree -= 180 + }, + label = { + Text( + text = + when (selectedOption) { + OptionStats.WEEKS -> stringResource(id = R.string.weeks) + OptionStats.MONTHS -> stringResource(id = R.string.months) + OptionStats.YEARS -> stringResource(id = R.string.years) + OptionStats.CONTINUOUS -> stringResource(id = R.string.continuous) + }, + ) + }, + trailingIcon = { + Icon( + painter = painterResource(R.drawable.expand_more), + contentDescription = null, + modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation), + ) + }, + shape = RoundedCornerShape(16.dp), + border = null, + colors = AssistChipDefaults.assistChipColors( + containerColor = containerColor, + labelColor = MaterialTheme.colorScheme.onSurface + ) + ) + + AnimatedVisibility( + visible = expanded, + enter = expandIn() + fadeIn(), + exit = shrinkOut() + fadeOut(), + ) { + DropdownMenu( + modifier = Modifier.padding(start = 12.dp), + expanded = expanded, + onDismissRequest = { + expanded = false + expandIconDegree -= 180 + }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(text = option.second) }, + onClick = { + onSelectionChange(option.first) + expandIconDegree -= 180 + expanded = false + }, + ) + } + } + } + } + + AnimatedContent( + targetState = selectedOption, + transitionSpec = { slideInHorizontally() + fadeIn() togetherWith slideOutHorizontally() + fadeOut() }, + label = "", + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) { + chips.forEach { (value, label) -> + Spacer(Modifier.width(8.dp)) + + FilterChip( + label = { Text(label) }, + selected = currentValue == value, + colors = FilterChipDefaults.filterChipColors( + containerColor = containerColor, + ), + onClick = { onValueUpdate(value) }, + shape = RoundedCornerShape(16.dp), + border = null + ) + } + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/CircleIconButton.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/CircleIconButton.kt new file mode 100644 index 00000000..d9d28e65 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/CircleIconButton.kt @@ -0,0 +1,75 @@ +package com.darkxvenom.airbeats.ui.component + +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.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.draw.scale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +/** + * Animated version (used when scroll offset exists) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CircleIconButton( + icon: Int, + onClick: () -> Unit, + scrollOffset: Float = 0f, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(52.dp - (8 * scrollOffset).dp) + .clip(CircleShape) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy( + alpha = 1f - (scrollOffset * 0.3f).coerceIn(0f, 0.3f) + ) + ) + .clickable { onClick() } + .scale(1f - (scrollOffset * 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier.size(24.dp - (4 * scrollOffset).dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Normal version (default use) + */ +@Composable +fun CircleIconButton( + icon: Int, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/CreatePlaylistDialog.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/CreatePlaylistDialog.kt new file mode 100644 index 00000000..37851be1 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/CreatePlaylistDialog.kt @@ -0,0 +1,93 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.foundation.layout.Arrangement +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.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@Composable +fun CreatePlaylistDialog( + onDismiss: () -> Unit, + initialTextFieldValue: String? = null, + allowSyncing: Boolean = true, +) { + val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + var syncedPlaylist by remember { mutableStateOf(false) } + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.add), contentDescription = null) }, + title = { Text(text = stringResource(R.string.create_playlist)) }, + initialTextFieldValue = TextFieldValue(initialTextFieldValue ?: ""), + onDismiss = onDismiss, + onDone = { playlistName -> + coroutineScope.launch(Dispatchers.IO) { + val browseId = if (syncedPlaylist) + YouTube.createPlaylist(playlistName) + else null + database.query { + insert( + PlaylistEntity( + name = playlistName, + browseId = browseId.toString(), + bookmarkedAt = LocalDateTime.now(), + isEditable = true, + ) + ) + } + } + }, + extraContent = { + if (allowSyncing) { + Row( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 40.dp) + ) { + Column { + Text( + text = stringResource(R.string.sync_playlist), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.allows_for_sync_witch_youtube), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth(0.7f) + ) + } + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.End + ) { + Switch( + checked = syncedPlaylist, + onCheckedChange = { + syncedPlaylist = !syncedPlaylist + }, + ) + } + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/CurvedBottomNavigationBar.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/CurvedBottomNavigationBar.kt new file mode 100644 index 00000000..156d220b --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/CurvedBottomNavigationBar.kt @@ -0,0 +1,257 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +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.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.border +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.luminance + +@Composable +fun CurvedBottomNavigationBar( + modifier: Modifier = Modifier, + items: List, + selectedIndex: Int, + onItemSelected: (Int) -> Unit +) { + val surfaceColor = MaterialTheme.colorScheme.surface + val isDarkTheme = surfaceColor.luminance() < 0.5f + + val glassBackgroundBrush = remember(isDarkTheme) { + if (isDarkTheme) { + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.15f), // Top highlight + Color(0xFF121212).copy(alpha = 0.75f), + Color(0xFF000000).copy(alpha = 0.85f) + ) + ) + } else { + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.95f), + Color.White.copy(alpha = 0.65f), + Color.White.copy(alpha = 0.55f) + ) + ) + } + } + + val rimBorderBrush = remember(isDarkTheme) { + if (isDarkTheme) { + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.4f), + Color.White.copy(alpha = 0.05f) + ) + ) + } else { + Brush.verticalGradient( + colors = listOf( + Color.White, + Color.White.copy(alpha = 0.5f) + ) + ) + } + } + + val unselectedIconColor = if (isDarkTheme) { + Color.White.copy(alpha = 0.7f) + } else { + Color.Black.copy(alpha = 0.5f) + } + + val indicatorBrush = remember(isDarkTheme) { + if (isDarkTheme) { + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.2f), + Color(0xFF222222), + Color(0xFF111111) + ) + ) + } else { + Brush.verticalGradient( + colors = listOf( + Color.White, + Color(0xFFEEEEEE), + Color(0xFFDDDDDD) + ) + ) + } + } + + val indicatorBorderColor = if (isDarkTheme) { + Color.White.copy(alpha = 0.3f) + } else { + Color.White.copy(alpha = 0.9f) + } + BoxWithConstraints(modifier = modifier) { + val width = maxWidth.value + val itemWidth = width / items.size + + // Animate the offset of the cutout based on selectedIndex + val animatedOffset by animateFloatAsState( + targetValue = (itemWidth * selectedIndex) + (itemWidth / 2f), + animationSpec = spring(stiffness = Spring.StiffnessLow, dampingRatio = Spring.DampingRatioMediumBouncy), + label = "offset" + ) + + // Draw the curved background with liquid glass effect + val density = LocalDensity.current + val barShape = CurvedBottomBarShape( + cutoutCenterX = with(density) { animatedOffset.dp.toPx() }, + cutoutCenterY = with(density) { 24.dp.toPx() }, + cutoutRadius = with(density) { 30.dp.toPx() } + ) + + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + shape = barShape + clip = true + shadowElevation = 12.dp.toPx() + } + .background(brush = glassBackgroundBrush) + .border( + width = 1.dp, + brush = rimBorderBrush, + shape = barShape + ) + ) + + // Draw the floating circle (indicator) + Box( + modifier = Modifier + .offset(x = (animatedOffset - 24).dp, y = 0.dp) + .size(48.dp) + .graphicsLayer { + shadowElevation = 8.dp.toPx() + shape = CircleShape + clip = true + } + .background(brush = indicatorBrush) + .border( + width = 1.dp, + color = indicatorBorderColor, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + // The selected icon + Icon( + painter = painterResource(id = items[selectedIndex].iconActive), + contentDescription = null, + tint = Color(0xFFF9A825), // Amber/Orange to match screenshot + modifier = Modifier.size(24.dp) + ) + } + + // Draw the unselected icons in a row + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + items.forEachIndexed { index, item -> + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onItemSelected(index) }, + contentAlignment = Alignment.Center + ) { + if (index != selectedIndex) { + Icon( + painter = painterResource(id = item.iconInactive), + contentDescription = null, + tint = unselectedIconColor, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } +} + +data class CurvedBottomNavigationItem( + val iconInactive: Int, + val iconActive: Int, + val titleId: Int = 0 +) + +class CurvedBottomBarShape( + private val cutoutCenterX: Float, + private val cutoutCenterY: Float, + private val cutoutRadius: Float +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val cornerRadius = size.height / 2f // Proper pill shape + + val pillPath = Path().apply { + addRoundRect( + androidx.compose.ui.geometry.RoundRect( + left = 0f, + top = 0f, + right = size.width, + bottom = size.height, + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadius, cornerRadius) + ) + ) + } + + val cutoutPath = Path().apply { + addOval( + androidx.compose.ui.geometry.Rect( + center = androidx.compose.ui.geometry.Offset(cutoutCenterX, cutoutCenterY), + radius = cutoutRadius + ) + ) + } + + val resultPath = Path() + resultPath.op(pillPath, cutoutPath, androidx.compose.ui.graphics.PathOperation.Difference) + + return Outline.Generic(resultPath) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/CustomAvatarSelector.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/CustomAvatarSelector.kt new file mode 100644 index 00000000..e559cb48 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/CustomAvatarSelector.kt @@ -0,0 +1,820 @@ +package com.darkxvenom.airbeats.ui.component + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.navigationBarsPadding +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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.setValue +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.darkxvenom.airbeats.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import kotlin.random.Random + +// Extension property para el DataStore +val Context.avatarDataStore: DataStore by preferencesDataStore(name = "avatar_preferences") + +/** + * Gestor de preferencias para el avatar + */ +class AvatarPreferenceManager(private val context: Context) { + companion object { + private val SELECTED_AVATAR_TYPE_KEY = stringPreferencesKey("selected_avatar_type") + private val CUSTOM_AVATAR_URI_KEY = stringPreferencesKey("custom_avatar_uri") + private val DICEBEAR_AVATAR_URL_KEY = stringPreferencesKey("dicebear_avatar_url") + } + + suspend fun saveAvatarSelection(selection: AvatarSelection) { + context.avatarDataStore.edit { preferences -> + when (selection) { + is AvatarSelection.Default -> { + preferences[SELECTED_AVATAR_TYPE_KEY] = "default" + preferences.remove(CUSTOM_AVATAR_URI_KEY) + preferences.remove(DICEBEAR_AVATAR_URL_KEY) + } + is AvatarSelection.DiceBear -> { + preferences[SELECTED_AVATAR_TYPE_KEY] = "dicebear" + preferences[DICEBEAR_AVATAR_URL_KEY] = selection.url + preferences.remove(CUSTOM_AVATAR_URI_KEY) + } + is AvatarSelection.Custom -> { + preferences[SELECTED_AVATAR_TYPE_KEY] = "custom" + preferences[CUSTOM_AVATAR_URI_KEY] = selection.uri + preferences.remove(DICEBEAR_AVATAR_URL_KEY) + } + } + } + } + + val getAvatarSelection: Flow = context.avatarDataStore.data + .map { preferences -> + val type = preferences[SELECTED_AVATAR_TYPE_KEY] ?: "default" + when (type) { + "dicebear" -> { + val url = preferences[DICEBEAR_AVATAR_URL_KEY] + if (url != null) AvatarSelection.DiceBear(url) else AvatarSelection.Default + } + "custom" -> { + val uri = preferences[CUSTOM_AVATAR_URI_KEY] + if (uri != null) AvatarSelection.Custom(uri) else AvatarSelection.Default + } + else -> AvatarSelection.Default + } + } +} + +/** + * Tipos de selección de avatar + */ +sealed class AvatarSelection { + object Default : AvatarSelection() + data class DiceBear(val url: String) : AvatarSelection() + data class Custom(val uri: String) : AvatarSelection() +} + +/** + * Estilos disponibles de DiceBear + */ +enum class DiceBearStyle(val value: String, val displayName: String) { + ADVENTURER("adventurer", "Adventurer"), + ADVENTURER_NEUTRAL("adventurer-neutral", "Adventurer Neutral"), + AVATAAARS("avataaars", "Avataaars"), + AVATAAARS_NEUTRAL("avataaars-neutral", "Avataaars Neutral"), + BIG_EARS("big-ears", "Big Ears"), + BIG_EARS_NEUTRAL("big-ears-neutral", "Big Ears Neutral"), + BIG_SMILE("big-smile", "Big Smile"), + BOTTTS("bottts", "Bottts"), + BOTTTS_NEUTRAL("bottts-neutral", "Bottts Neutral"), + CROODLES("croodles", "Croodles"), + CROODLES_NEUTRAL("croodles-neutral", "Croodles Neutral"), + DYLAN("dylan", "Dylan"), + FUN_EMOJI("fun-emoji", "Fun Emoji"), + GLASS("glass", "Glass"), + ICONS("icons", "Icons"), + IDENTICON("identicon", "Identicon"), + INITIALS("initials", "Initials"), + LORELEI("lorelei", "Lorelei"), + LORELEI_NEUTRAL("lorelei-neutral", "Lorelei Neutral"), + MICAH("micah", "Micah"), + MINIAVS("miniavs", "Miniavs"), + NOTIONISTS("notionists", "Notionists"), + NOTIONISTS_NEUTRAL("notionists-neutral", "Notionists Neutral"), + OPEN_PEEPS("open-peeps", "Open Peeps"), + PERSONAS("personas", "Personas"), + PIXEL_ART("pixel-art", "Pixel Art"), + PIXEL_ART_NEUTRAL("pixel-art-neutral", "Pixel Art Neutral"), + RINGS("rings", "Rings"), + SHAPES("shapes", "Shapes"), + THUMBS("thumbs", "Thumbs") +} + + +/** + * Generador de URLs de DiceBear + */ +object DiceBearGenerator { + private const val BASE_URL = "https://api.dicebear.com/7.x" + + fun generateAvatarUrl( + style: DiceBearStyle = DiceBearStyle.SHAPES, + seed: String? = null, + size: Int = 200, + backgroundColor: String? = null, + format: String = "png" // 👈 antes estaba "svg" + ): String { + val actualSeed = seed ?: generateRandomSeed() + var url = "$BASE_URL/${style.value}/$format?seed=$actualSeed&size=$size" + + backgroundColor?.let { url += "&backgroundColor=$it" } + + return url + } + + + fun generateRandomSeed(): String { + return Random.nextLong(0, Long.MAX_VALUE).toString() + } + + fun getPresetAvatars(style: DiceBearStyle = DiceBearStyle.SHAPES): List { + val seeds = listOf( + "Amaya", + "Destiny", + "Sarah", + "Alexander", + "Jack", + "Wyatt", + "Emery", + "Jameson", + "Avery", + "Sara", + "Aiden", + "Sophia", + "Riley", + "Brian", + "Jude", + "Luis", + "Christian", + "Eliza", + "Leo", + "Vivian" + ) + return seeds.map { generateAvatarUrl(style, it) } + } + +} + +/** + * Estado de la UI para el avatar + */ +data class AvatarUiState( + val isLoading: Boolean = false, + val error: String? = null, + val currentSelection: AvatarSelection = AvatarSelection.Default, + val showDiceBearDialog: Boolean = false +) + +/** + * Componente principal del selector de avatar + */ +@Composable +fun AvatarSelector( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val avatarManager = remember { AvatarPreferenceManager(context) } + val currentSelection by avatarManager.getAvatarSelection.collectAsState(initial = AvatarSelection.Default) + + var uiState by remember { + mutableStateOf(AvatarUiState(currentSelection = currentSelection)) + } + + // Actualizar el estado cuando cambia la selección + LaunchedEffect(currentSelection) { + uiState = uiState.copy(currentSelection = currentSelection) + } + + val coroutineScope = rememberCoroutineScope() + + // Launcher para seleccionar imagen personalizada + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { + uiState = uiState.copy(isLoading = true, error = null) + + coroutineScope.launch { + val result = saveImageToInternalStorage(context, it) + result.fold( + onSuccess = { savedFile -> + val savedUri = Uri.fromFile(savedFile) + avatarManager.saveAvatarSelection(AvatarSelection.Custom(savedUri.toString())) + }, + onFailure = { exception -> + uiState = uiState.copy( + error = context.getString(R.string.error_saving_image), + isLoading = false + ) + Log.e("AvatarSelector", "Error saving image", exception) + } + ) + uiState = uiState.copy(isLoading = false) + } + } + } + + // Mostrar error temporalmente + uiState.error?.let { error -> + LaunchedEffect(error) { + delay(4000) + uiState = uiState.copy(error = null) + } + } + + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Título + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(id = R.string.avatar_selection), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.width(8.dp)) + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Avatar actual seleccionado + CurrentAvatarDisplay( + selection = uiState.currentSelection, + isLoading = uiState.isLoading, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Botones de acción + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { galleryLauncher.launch("image/*") }, + modifier = Modifier.weight(1f), + enabled = !uiState.isLoading + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(id = R.string.custom)) + } + + Button( + onClick = { + uiState = uiState.copy(showDiceBearDialog = true) + }, + modifier = Modifier.weight(1f), + enabled = !uiState.isLoading + ) { + Icon( + painter = painterResource(R.drawable.palette), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Avatars") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Botón para restaurar por defecto + OutlinedButton( + onClick = { + coroutineScope.launch { + cleanupOldAvatars(context) + avatarManager.saveAvatarSelection(AvatarSelection.Default) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = uiState.currentSelection !is AvatarSelection.Default && !uiState.isLoading + ) { + Text(stringResource(id = R.string.restore_default_avatar)) + } + + // Mostrar mensaje de error + AnimatedVisibility(visible = uiState.error != null) { + uiState.error?.let { error -> + Spacer(modifier = Modifier.height(12.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + } + + // Dialog para seleccionar avatars DiceBear + if (uiState.showDiceBearDialog) { + DiceBearAvatarDialog( + onDismiss = { + uiState = uiState.copy(showDiceBearDialog = false) + }, + onAvatarSelected = { url -> + coroutineScope.launch { + avatarManager.saveAvatarSelection(AvatarSelection.DiceBear(url)) + } + uiState = uiState.copy(showDiceBearDialog = false) + }, + currentSelection = uiState.currentSelection + ) + } +} + +/** + * Dialog para seleccionar avatars de DiceBear + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DiceBearAvatarDialog( + onDismiss: () -> Unit, + onAvatarSelected: (String) -> Unit, + currentSelection: AvatarSelection +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var selectedStyle by remember { mutableStateOf(DiceBearStyle.SHAPES) } + var avatarUrls by remember { mutableStateOf(DiceBearGenerator.getPresetAvatars(selectedStyle)) } + var isLoading by remember { mutableStateOf(false) } + var showStyleDialog by remember { mutableStateOf(false) } + + // 🔄 Regenera avatares al cambiar estilo + LaunchedEffect(selectedStyle) { + isLoading = true + avatarUrls = DiceBearGenerator.getPresetAvatars(selectedStyle) + isLoading = false + } + + // BottomSheet principal + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + dragHandle = { BottomSheetDefaults.DragHandle() } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Título + Text( + text = "Selecciona un avatar", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + // Fila: estilo actual + randomizar + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Botón para abrir selección de estilo + FilledTonalButton( + onClick = { showStyleDialog = true }, + modifier = Modifier.weight(1f) + ) { + Text(selectedStyle.displayName) + } + + // Botón randomizar con tu ícono + IconButton( + onClick = { + isLoading = true + avatarUrls = DiceBearGenerator.getPresetAvatars(selectedStyle) + isLoading = false + } + ) { + Icon( + painter = painterResource(R.drawable.ia_icon), + contentDescription = "Randomizar avatares" + ) + } + } + + // Grid de avatares + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxHeight(0.7f) + ) { + items(avatarUrls) { url -> + Card( + modifier = Modifier + .aspectRatio(1f) + .clickable { + onAvatarSelected(url) + onDismiss() + }, + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + AsyncImage( + model = url, + contentDescription = "Avatar DiceBear", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } + } + } + + // Dialog para seleccionar estilo + if (showStyleDialog) { + Dialog( + onDismissRequest = { showStyleDialog = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.85f) + .fillMaxHeight(0.7f), // máximo 70% de altura de pantalla + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .padding(16.dp) + ) { + Text( + text = "Elige un estilo", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.height(8.dp)) + + LazyColumn( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(DiceBearStyle.values()) { style -> + TextButton( + onClick = { + selectedStyle = style + showStyleDialog = false + }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = style.displayName, + style = MaterialTheme.typography.bodyLarge, + color = if (style == selectedStyle) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } + } + +} + + + +/** + * Item individual de avatar DiceBear + */ +@Composable +private fun DiceBearAvatarItem( + url: String, + isSelected: Boolean, + onSelected: () -> Unit +) { + val borderColor = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + } + + val borderWidth = if (isSelected) 3.dp else 1.dp + + Box( + modifier = Modifier + .aspectRatio(1f) + .clip(CircleShape) + .border(BorderStroke(borderWidth, borderColor), CircleShape) + .clickable { onSelected() }, + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(url) + .crossfade(true) + .build(), + contentDescription = "Avatar DiceBear", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // Indicador de selección + if (isSelected) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "Seleccionado", + modifier = Modifier + .size(24.dp) + .clip(CircleShape), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +/** + * Muestra el avatar actualmente seleccionado + */ +@Composable +private fun CurrentAvatarDisplay( + selection: AvatarSelection, + isLoading: Boolean, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + Box( + modifier = modifier + .size(80.dp) + .clip(CircleShape) + .border( + width = 3.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + strokeWidth = 3.dp, + color = MaterialTheme.colorScheme.primary + ) + } + + selection is AvatarSelection.Custom -> { + AsyncImage( + model = ImageRequest.Builder(context) + .data(Uri.parse(selection.uri)) + .crossfade(true) + .error(R.drawable.person) + .placeholder(R.drawable.person) + .build(), + contentDescription = stringResource(id = R.string.custom_avatar), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + selection is AvatarSelection.DiceBear -> { + AsyncImage( + model = ImageRequest.Builder(context) + .data(selection.url) + .crossfade(true) + .error(R.drawable.person) + .placeholder(R.drawable.person) + .build(), + contentDescription = "Avatar DiceBear", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + else -> { + DefaultAvatarIcon() + } + } + } +} + +/** + * Icono de avatar por defecto + */ +@Composable +private fun DefaultAvatarIcon() { + Icon( + painter = painterResource(id = R.drawable.person), + contentDescription = stringResource(id = R.string.default_avatar), + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +// Funciones utilitarias (las mismas del código anterior) +private suspend fun saveImageToInternalStorage( + context: Context, + uri: Uri +): Result = withContext(Dispatchers.IO) { + try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + cleanupOldAvatars(context) + + val fileName = "custom_avatar_${System.currentTimeMillis()}.jpg" + val outputFile = File(context.filesDir, fileName) + + val bitmap = BitmapFactory.decodeStream(inputStream) + val compressedBitmap = resizeAndCompressBitmap(bitmap, 500, 500) + + FileOutputStream(outputFile).use { outputStream -> + compressedBitmap.compress(Bitmap.CompressFormat.JPEG, 85, outputStream) + } + + if (bitmap != compressedBitmap) { + bitmap.recycle() + } + compressedBitmap.recycle() + + Result.success(outputFile) + } ?: Result.failure(Exception("No se pudo abrir el archivo")) + } catch (e: Exception) { + Log.e("AvatarSelector", "Error saving image to internal storage", e) + Result.failure(e) + } +} + +private fun resizeAndCompressBitmap( + bitmap: Bitmap, + maxWidth: Int, + maxHeight: Int +): Bitmap { + val width = bitmap.width + val height = bitmap.height + + val ratio = minOf(maxWidth.toFloat() / width, maxHeight.toFloat() / height) + + return if (ratio < 1.0f) { + val newWidth = (width * ratio).toInt() + val newHeight = (height * ratio).toInt() + Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) + } else { + bitmap + } +} + +private fun cleanupOldAvatars(context: Context) { + try { + context.filesDir.listFiles()?.forEach { file -> + if (file.name.startsWith("custom_avatar_") && file.name.endsWith(".jpg")) { + val deleted = file.delete() + Log.d("AvatarSelector", "Deleted old avatar file: ${file.name}, success: $deleted") + } + } + } catch (e: Exception) { + Log.e("AvatarSelector", "Error cleaning up old avatars", e) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/Dialog.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Dialog.kt new file mode 100644 index 00000000..15d0500c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Dialog.kt @@ -0,0 +1,320 @@ +package com.darkxvenom.airbeats.ui.component + +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.RowScope +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.res.painterResource +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.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.darkxvenom.airbeats.R +import kotlinx.coroutines.delay + +@Composable +fun DefaultDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + buttons: (@Composable RowScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + modifier + .padding(24.dp), + ) { + if (icon != null) { + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.iconContentColor) { + Box( + Modifier.align(Alignment.CenterHorizontally), + ) { + icon() + } + } + + Spacer(Modifier.height(16.dp)) + } + if (title != null) { + CompositionLocalProvider(LocalContentColor provides AlertDialogDefaults.titleContentColor) { + ProvideTextStyle(MaterialTheme.typography.headlineSmall) { + Box( + // Align the title to the center when an icon is present. + Modifier.align(if (icon == null) Alignment.Start else Alignment.CenterHorizontally), + ) { + title() + } + } + } + + Spacer(Modifier.height(16.dp)) + } + + content() + + if (buttons != null) { + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.align(Alignment.End), + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { + ProvideTextStyle( + value = MaterialTheme.typography.labelLarge, + ) { + buttons() + } + } + } + } + } + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionPromptDialog( + title: String? = null, + titleBar: @Composable (RowScope.() -> Unit)? = null, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + onReset: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit = {} +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column( + modifier = Modifier.padding(24.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + // title + if (titleBar != null) { + Row { + titleBar() + } + } else if (title != null) { + Text( + text = title, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(16.dp)) + } + + content() // body + } + + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + if (onReset != null) { + Row(modifier = Modifier.weight(1f)) { + TextButton( + onClick = { onReset() }, + ) { + Text(stringResource(R.string.reset)) + } + } + } + + if (onCancel != null) { + TextButton( + onClick = { onCancel() } + ) { + Text(stringResource(android.R.string.cancel)) + } + } + + TextButton( + onClick = { onConfirm() } + ) { + Text(stringResource(android.R.string.ok)) + } + } + } + } + } +} + +@Composable +fun ListDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: LazyListScope.() -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier.padding(24.dp), + shape = AlertDialogDefaults.shape, + color = AlertDialogDefaults.containerColor, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.padding(vertical = 24.dp), + ) { + LazyColumn(content = content) + } + } + } +} + +@Composable +fun InfoLabel( + text: String +) = Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp) +) { + Icon( + painter = painterResource(id = R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(4.dp) + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 4.dp) + ) +} + +@Composable +fun TextFieldDialog( + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null, + title: (@Composable () -> Unit)? = null, + initialTextFieldValue: TextFieldValue = TextFieldValue(), + placeholder: @Composable (() -> Unit)? = null, + singleLine: Boolean = true, + autoFocus: Boolean = true, + maxLines: Int = if (singleLine) 1 else 10, + isInputValid: (String) -> Boolean = { it.isNotEmpty() }, + onDone: (String) -> Unit, + onDismiss: () -> Unit, + extraContent: (@Composable () -> Unit)? = null, +) { + val (textFieldValue, onTextFieldValueChange) = + remember { + mutableStateOf(initialTextFieldValue) + } + + val focusRequester = + remember { + FocusRequester() + } + + LaunchedEffect(Unit) { + if (autoFocus) { + delay(300) + focusRequester.requestFocus() + } + } + + DefaultDialog( + onDismiss = onDismiss, + modifier = modifier, + icon = icon, + title = title, + buttons = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + enabled = isInputValid(textFieldValue.text), + onClick = { + onDismiss() + onDone(textFieldValue.text) + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) { + TextField( + value = textFieldValue, + onValueChange = onTextFieldValueChange, + placeholder = placeholder, + singleLine = singleLine, + maxLines = maxLines, + colors = + OutlinedTextFieldDefaults.colors(), + keyboardOptions = KeyboardOptions(imeAction = if (singleLine) ImeAction.Done else ImeAction.None), + keyboardActions = + KeyboardActions( + onDone = { + onDone(textFieldValue.text) + onDismiss() + }, + ), + modifier = + Modifier + .weight(weight = 1f, fill = false) + .focusRequester(focusRequester), + ) + extraContent?.invoke() + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/EmptyPlaceholder.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/EmptyPlaceholder.kt new file mode 100644 index 00000000..502c074a --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/EmptyPlaceholder.kt @@ -0,0 +1,47 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.unit.dp + +@Composable +fun EmptyPlaceholder( + @DrawableRes icon: Int, + text: String, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + modifier + .fillMaxSize() + .padding(12.dp), + ) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(64.dp), + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/EnhancedPreferenceEntry.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/EnhancedPreferenceEntry.kt new file mode 100644 index 00000000..f84b13de --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/EnhancedPreferenceEntry.kt @@ -0,0 +1,231 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.animation.animateContentSize +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.R + +/** Enhanced version of PreferenceEntry with icon and improved design */ +@Composable +fun EnhancedPreferenceEntry( + title: String, + icon: Int, + modifier: Modifier = Modifier, + subtitle: String? = null, + iconTint: Color = MaterialTheme.colorScheme.primary, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = Color.Transparent, + enabled = enabled, + onClick = onClick + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + iconTint.copy(alpha = 0.2f), + iconTint.copy(alpha = 0.05f) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(24.dp) + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.6f + ) + ) + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.6f + ) + ) + } + } + + Icon( + painter = painterResource(R.drawable.chevron_right), + contentDescription = null, + tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = .6f + ) + ) + } + } +} + +/** Enhanced version of ListPreference with icon and improved design */ +@Composable +fun EnhancedListPreference( + title: String, + icon: Int, + values: List, + selectedValue: T, + valueText: @Composable (T) -> String, + modifier: Modifier = Modifier, + subtitle: String? = null, + onValueSelected: (T) -> Unit, +) { + var showDialog by remember { mutableStateOf(false) } + + EnhancedPreferenceEntry( + title = title, + subtitle = subtitle ?: valueText(selectedValue), + icon = icon, + modifier = modifier, + onClick = { showDialog = true }, + ) + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text(title) }, + text = { + Column( + modifier = Modifier.animateContentSize() + ) { + values.forEach { value -> + val isSelected = value == selectedValue + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onValueSelected(value) + showDialog = false + } + .padding(vertical = 12.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + RadioButton( + selected = isSelected, + onClick = { + onValueSelected(value) + showDialog = false + } + ) + Text( + text = valueText(value), + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } + if (value != values.last()) { + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(R.string.close)) + } + } + ) + } +} + +/** Enhanced Icon Button with animation support */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AnimatedIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, + content: @Composable () -> Unit +) { + Surface( + shape = CircleShape, + color = Color.Transparent, + modifier = modifier + .size(48.dp) + .then( + if (onLongClick != null) { + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + enabled = enabled + ) + } else { + Modifier.clickable( + onClick = onClick, + enabled = enabled + ) + } + ) + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + content() + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/GridMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/GridMenu.kt new file mode 100644 index 00000000..ffaf4b13 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/GridMenu.kt @@ -0,0 +1,194 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Text +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.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.media3.exoplayer.offline.Download +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.utils.makeTimeString + +val GridMenuItemHeight = 108.dp + +@Composable +fun GridMenu( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + content: LazyGridScope.() -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + modifier = modifier, + contentPadding = contentPadding, + content = content + ) +} + +fun LazyGridScope.GridMenuItem( + modifier: Modifier = Modifier, + @DrawableRes icon: Int, + tint: @Composable () -> Color = { LocalContentColor.current }, + @StringRes title: Int, + enabled: Boolean = true, + onClick: () -> Unit, +) = GridMenuItem( + modifier = modifier, + icon = { + Icon( + painter = painterResource(icon), + tint = tint(), + contentDescription = null + ) + }, + title = title, + enabled = enabled, + onClick = onClick +) + +fun LazyGridScope.GridMenuItem( + modifier: Modifier = Modifier, + icon: @Composable BoxScope.() -> Unit, + @StringRes title: Int, + enabled: Boolean = true, + onClick: () -> Unit, +) { + item { + Column( + modifier = modifier + .clip(ShapeDefaults.Large) + .height(GridMenuItemHeight) + .clickable( + enabled = enabled, + onClick = onClick + ) + .alpha(if (enabled) 1f else 0.5f) + .padding(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + content = icon + ) + Text( + text = stringResource(title), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + maxLines = 2, + modifier = Modifier + .fillMaxWidth() + .height(with(LocalDensity.current) { + MaterialTheme.typography.labelLarge.lineHeight.toDp() * 2 + }) + ) + } + } +} + + +fun LazyGridScope.DownloadGridMenu( + @Download.State state: Int?, + onRemoveDownload: () -> Unit, + onDownload: () -> Unit, +) { + when (state) { + Download.STATE_COMPLETED -> { + GridMenuItem( + icon = R.drawable.offline, + title = R.string.remove_download, + onClick = onRemoveDownload + ) + } + + Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { + GridMenuItem( + icon = { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + }, + title = R.string.downloading, + onClick = onRemoveDownload + ) + } + + else -> { + GridMenuItem( + icon = R.drawable.download, + title = R.string.download, + onClick = onDownload + ) + } + } +} + +fun LazyGridScope.SleepTimerGridMenu( + modifier: Modifier = Modifier, + sleepTimerTimeLeft: Long, + enabled: Boolean = true, + onClick: () -> Unit +) { + item { + Column( + modifier = modifier + .clip(ShapeDefaults.Large) + .height(GridMenuItemHeight) + .clickable( + onClick = onClick + ) + .padding(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + content = { + Icon( + painterResource(R.drawable.bedtime), + contentDescription = null, + modifier = Modifier.alpha(if (enabled) 1f else 0.5f) + ) + } + ) + Text( + text = if (enabled) makeTimeString(sleepTimerTimeLeft) else stringResource( + id = R.string.sleep_timer + ), + style = MaterialTheme.typography.labelLarge, + textAlign = TextAlign.Center, + maxLines = 2, + modifier = Modifier.fillMaxWidth() + ) + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/HideOnScrollFAB.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/HideOnScrollFAB.kt new file mode 100644 index 00000000..4d0646ce --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/HideOnScrollFAB.kt @@ -0,0 +1,116 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.ui.utils.isScrollingUp + +@Composable +fun BoxScope.HideOnScrollFAB( + visible: Boolean = true, + lazyListState: LazyListState, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible && lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), + ), + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = onClick, + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + ) + } + } +} + +@Composable +fun BoxScope.HideOnScrollFAB( + visible: Boolean = true, + lazyListState: LazyGridState, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible && lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), + ), + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = onClick, + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + ) + } + } +} + +@Composable +fun BoxScope.HideOnScrollFAB( + visible: Boolean = true, + scrollState: ScrollState, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible && scrollState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), + ), + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = onClick, + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + ) + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/IconButton.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/IconButton.kt new file mode 100644 index 00000000..57e6183d --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/IconButton.kt @@ -0,0 +1,92 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.darkxvenom.airbeats.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.Indication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +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.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp + +@Composable +fun ResizableIconButton( + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface, + enabled: Boolean = true, + indication: Indication? = null, + onClick: () -> Unit = {}, +) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = modifier + .clickable( + indication = indication ?: ripple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + enabled = enabled, + onClick = onClick, + ) + .alpha(if (enabled) 1f else 0.5f), + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun IconButton( + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: IconButtonColors = IconButtonDefaults.iconButtonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .clip(CircleShape) + .background(color = colors.containerColor) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = ripple( + bounded = false, + radius = 24.dp + ), + ), + contentAlignment = Alignment.Center, + ) { + val contentColor = colors.contentColor + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/Items.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Items.kt new file mode 100644 index 00000000..738fd8d7 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Items.kt @@ -0,0 +1,2105 @@ +@file:OptIn(ExperimentalFoundationApi::class) + +package com.darkxvenom.airbeats.ui.component + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +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.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +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.aspectRatio +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.setValue +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.Shape +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.zIndex +import androidx.core.graphics.drawable.toBitmapOrNull +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING +import androidx.media3.exoplayer.offline.Download.STATE_QUEUED +import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.request.ImageRequest +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.innertube.models.ArtistItem +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.models.YTItem +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.constants.SmallGridThumbnailHeight +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.db.entities.Artist +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.playback.queues.LocalAlbumRadio +import com.darkxvenom.airbeats.ui.theme.extractThemeColor +import com.darkxvenom.airbeats.utils.getPlaylistImageUri +import com.darkxvenom.airbeats.utils.joinByBullet +import com.darkxvenom.airbeats.utils.makeTimeString +import com.darkxvenom.airbeats.utils.reportException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +inline fun ListItem( + modifier: Modifier = Modifier, + title: String, + noinline subtitle: (@Composable RowScope.() -> Unit)? = null, + thumbnailContent: @Composable () -> Unit, + trailingContent: @Composable RowScope.() -> Unit = {}, + isActive: Boolean = false, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + if (isActive) { + modifier + .height(ListItemHeight) + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color = MaterialTheme.colorScheme.secondaryContainer) + } else { + modifier + .height(ListItemHeight) + .padding(horizontal = 8.dp) + }, + ) { + Box( + modifier = Modifier.padding(6.dp), + contentAlignment = Alignment.Center, + ) { + thumbnailContent() + } + Column( + modifier = + Modifier + .weight(1f) + .padding(horizontal = 6.dp), + ) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .basicMarquee() + .fillMaxWidth(), + ) + + if (subtitle != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + subtitle() + } + } + } + + trailingContent() + } +} + +@Composable +fun ListItem( + modifier: Modifier = Modifier, + title: String, + subtitle: String?, + badges: @Composable RowScope.() -> Unit = {}, + thumbnailContent: @Composable () -> Unit, + trailingContent: @Composable RowScope.() -> Unit = {}, + isActive: Boolean = false, +) = ListItem( + title = title, + subtitle = { + badges() + + if (!subtitle.isNullOrEmpty()) { + Text( + text = subtitle, + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + thumbnailContent = thumbnailContent, + trailingContent = trailingContent, + modifier = modifier, + isActive = isActive, +) + +@Composable +fun GridItem( + modifier: Modifier = Modifier, + title: String, + subtitle: String, + badges: @Composable RowScope.() -> Unit = {}, + thumbnailContent: @Composable BoxWithConstraintsScope.() -> Unit, + thumbnailShape: Shape, + thumbnailRatio: Float = 1f, + fillMaxWidth: Boolean = false, +) { + Column( + modifier = + if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight * thumbnailRatio) + + }, + ) { + BoxWithConstraints( + contentAlignment = + Alignment.Center, + modifier = + if (fillMaxWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.height(GridThumbnailHeight) + } + .aspectRatio(thumbnailRatio) + .clip(RoundedCornerShape(27.dp)), + ) { + thumbnailContent() + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + modifier = + Modifier + .basicMarquee() + .fillMaxWidth(), + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + badges() + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun SmallGridItem( + modifier: Modifier = Modifier, + title: String, + thumbnailContent: @Composable BoxWithConstraintsScope.() -> Unit, + thumbnailShape: Shape, + thumbnailRatio: Float = 1f, + isArtist: Boolean? = false, +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = if (isArtist == true) Alignment.CenterHorizontally else Alignment.Start, + modifier = + modifier + .fillMaxHeight() + .width(GridThumbnailHeight * thumbnailRatio) + .padding(12.dp), + ) { + BoxWithConstraints( + modifier = + Modifier + .height(SmallGridThumbnailHeight) + .aspectRatio(thumbnailRatio) + .clip(RoundedCornerShape(8.dp)) + ) { + thumbnailContent() + } + + Spacer(modifier = Modifier.height(6.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.width(SmallGridThumbnailHeight), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + modifier = + Modifier + .basicMarquee() + .fillMaxWidth(), + ) + } + } +} + +@Composable +fun SongListItem( + song: Song, + modifier: Modifier = Modifier, + albumIndex: Int? = null, + showLikedIcon: Boolean = true, + showInLibraryIcon: Boolean = false, + showDownloadIcon: Boolean = true, + isSelected: Boolean = false, + badges: @Composable RowScope.() -> Unit = { + if (showLikedIcon && song.song.liked) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + if (showInLibraryIcon && song.song.inLibrary != null) { + Icon( + painter = painterResource(R.drawable.library_add_check), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + if (showDownloadIcon) { + val download by LocalDownloadUtil.current.getDownload(song.id) + .collectAsState(initial = null) + when (download?.state) { + STATE_COMPLETED -> + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + + STATE_QUEUED, STATE_DOWNLOADING -> + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = + Modifier + .size(16.dp) + .padding(end = 2.dp), + ) + + else -> {} + } + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = song.song.title, + subtitle = + joinByBullet( + song.artists.joinToString { it.name }, + makeTimeString(song.song.duration * 1000L), + ), + badges = badges, + thumbnailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(ListThumbnailSize), + ) { + if (albumIndex != null) { + AnimatedVisibility( + visible = !isActive, + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut(), + ) { + if (isSelected) { + Icon( + painter = painterResource(R.drawable.done), + modifier = Modifier.align(Alignment.Center), + contentDescription = null, + ) + } else { + Text( + text = albumIndex.toString(), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } else { + if (isSelected) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .zIndex(1000f) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(Color.Black.copy(alpha = 0.5f)), + ) { + Icon( + painter = painterResource(R.drawable.done), + modifier = Modifier.align(Alignment.Center), + contentDescription = null, + ) + } + } + AsyncImage( + model = song.song.thumbnailUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + } + + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + color = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White, + modifier = + Modifier + .fillMaxSize() + .background( + color = + if (albumIndex != null) { + Color.Transparent + } else { + Color.Black.copy( + alpha = 0.4f, + ) + }, + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) + } + }, + trailingContent = trailingContent, + modifier = modifier, + isActive = isActive, +) + +@Composable +fun SongGridItem( + song: Song, + modifier: Modifier = Modifier, + showLikedIcon: Boolean = true, + showInLibraryIcon: Boolean = false, + showDownloadIcon: Boolean = true, + badges: @Composable RowScope.() -> Unit = { + if (showLikedIcon && song.song.liked) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (showInLibraryIcon && song.song.inLibrary != null) { + Icon( + painter = painterResource(R.drawable.library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (showDownloadIcon) { + val download by LocalDownloadUtil.current.getDownload(song.id) + .collectAsState(initial = null) + when (download?.state) { + STATE_COMPLETED -> Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + + STATE_QUEUED, STATE_DOWNLOADING -> CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(16.dp) + .padding(end = 2.dp) + ) + + else -> {} + } + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, +) = GridItem( + title = song.song.title, + subtitle = joinByBullet( + song.artists.joinToString { it.name }, + makeTimeString(song.song.duration * 1000L) + ), + badges = badges, + thumbnailContent = { + AsyncImage( + model = song.song.thumbnailUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + modifier = + Modifier + .align(Alignment.Center), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = if (isPlaying) 0.4f else 0f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp), + ) + } + } + } + + AnimatedVisibility( + visible = !(isActive && isPlaying), + enter = fadeIn(), + exit = fadeOut(), + modifier = + Modifier + .align(Alignment.Center) + .padding(8.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.6f)), + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + }, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth = fillMaxWidth, + modifier = modifier +) + +@Composable +fun SongSmallGridItem( + song: Song, + modifier: Modifier = Modifier, + isActive: Boolean = false, + isPlaying: Boolean = false, +) = SmallGridItem( + title = song.song.title, + thumbnailContent = { + AsyncImage( + model = song.song.thumbnailUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = if (isPlaying) 0.4f else 0f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp), + ) + } + } + } + + AnimatedVisibility( + visible = !(isActive && isPlaying), + enter = fadeIn(), + exit = fadeOut(), + modifier = + Modifier + .align(Alignment.Center) + .padding(8.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.6f)), + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + }, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = modifier, +) + +@Composable +fun ArtistListItem( + artist: Artist, + modifier: Modifier = Modifier, + badges: @Composable RowScope.() -> Unit = { + if (artist.artist.bookmarkedAt != null) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + }, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = artist.artist.name, + subtitle = pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount), + badges = badges, + thumbnailContent = { + AsyncImage( + model = artist.artist.thumbnailUrl, + contentDescription = null, + modifier = + Modifier + .size(ListThumbnailSize) + .clip(CircleShape), + ) + }, + trailingContent = trailingContent, + modifier = modifier, +) + +@Composable +fun ArtistGridItem( + artist: Artist, + modifier: Modifier = Modifier, + badges: @Composable RowScope.() -> Unit = { + if (artist.artist.bookmarkedAt != null) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + }, + fillMaxWidth: Boolean = false, +) = GridItem( + title = artist.artist.name, + subtitle = pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount), + badges = badges, + thumbnailContent = { + AsyncImage( + model = artist.artist.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + + ) + }, + thumbnailShape = CircleShape, + fillMaxWidth = fillMaxWidth, + modifier = modifier, +) + +@Composable +fun ArtistSmallGridItem( + artist: Artist, + modifier: Modifier = Modifier, +) = SmallGridItem( + title = artist.artist.name, + thumbnailContent = { + AsyncImage( + model = artist.artist.thumbnailUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + }, + thumbnailShape = CircleShape, + modifier = modifier, + isArtist = true, +) + +@Composable +fun AlbumListItem( + album: Album, + modifier: Modifier = Modifier, + showLikedIcon: Boolean = true, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + var songs by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.albumSongs(album.id).collect { + songs = it + } + } + + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == STATE_COMPLETED }) { + STATE_COMPLETED + } else if (songs.all { + downloads[it.id]?.state == STATE_QUEUED || + downloads[it.id]?.state == STATE_DOWNLOADING || + downloads[it.id]?.state == STATE_COMPLETED + } + ) { + STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + if (showLikedIcon && album.album.bookmarkedAt != null) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + + when (downloadState) { + STATE_COMPLETED -> + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + + STATE_DOWNLOADING -> + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = + Modifier + .size(16.dp) + .padding(end = 2.dp), + ) + + else -> {} + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = album.album.title, + subtitle = + joinByBullet( + album.artists.joinToString { it.name }, + pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount), + album.album.year?.toString(), + ), + badges = badges, + thumbnailContent = { + val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(album.album.thumbnailUrl) + .allowHardware(false) + .build(), + contentDescription = null, + onState = { state -> + if (album.album.themeColor == null && state is AsyncImagePainter.State.Success) { + coroutineScope.launch(Dispatchers.IO) { + state.result.drawable.toBitmapOrNull()?.extractThemeColor()?.toArgb() + ?.let { color -> + database.query { + update(album.album.copy(themeColor = color)) + } + } + } + } + }, + modifier = + Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + modifier = + Modifier + .size(ListThumbnailSize) + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) + }, + trailingContent = trailingContent, + modifier = modifier, +) + +@Composable +fun AlbumGridItem( + album: Album, + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + var songs by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.albumSongs(album.id).collect { + songs = it + } + } + + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == STATE_COMPLETED }) { + STATE_COMPLETED + } else if (songs.all { + downloads[it.id]?.state == STATE_QUEUED || + downloads[it.id]?.state == STATE_DOWNLOADING || + downloads[it.id]?.state == STATE_COMPLETED + } + ) { + STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + if (album.album.bookmarkedAt != null) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + + when (downloadState) { + STATE_COMPLETED -> + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + + STATE_DOWNLOADING -> + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = + Modifier + .size(16.dp) + .padding(end = 2.dp), + ) + + else -> {} + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, +) = GridItem( + title = album.album.title, + subtitle = album.artists.joinToString { it.name }, + badges = badges, + thumbnailContent = { + AsyncImage( + model = album.album.thumbnailUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp), + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + } + + AnimatedVisibility( + visible = !isActive, + enter = fadeIn(), + exit = fadeOut(), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(8.dp), + ) { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return@AnimatedVisibility + + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.4f)) + .clickable { + coroutineScope.launch { + database.albumWithSongs(album.id).first()?.let { albumWithSongs -> + playerConnection.playQueue( + LocalAlbumRadio(albumWithSongs) + ) + } + } + }, + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + }, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth = fillMaxWidth, + modifier = modifier, +) + +@Composable +fun AlbumSmallGridItem( + song: Song, + modifier: Modifier = Modifier, + isActive: Boolean = false, + isPlaying: Boolean = false, +) = song.song.albumName?.let { + SmallGridItem( + title = it, + thumbnailContent = { + AsyncImage( + model = song.song.thumbnailUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp), + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + } + }, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), + modifier = modifier, + ) +} + +@Composable +fun PlaylistListItem( + playlist: Playlist, + modifier: Modifier = Modifier, + trailingContent: @Composable RowScope.() -> Unit = {}, + autoPlaylist: Boolean = false, +) = ListItem( + title = playlist.playlist.name, + subtitle = if (autoPlaylist) { + "" + } else { + if (playlist.songCount == 0 && playlist.playlist.remoteSongCount != null) { + pluralStringResource( + R.plurals.n_song, + playlist.playlist.remoteSongCount, + playlist.playlist.remoteSongCount + ) + } else { + pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount) + } + }, + thumbnailContent = { + val painter = + when (playlist.playlist.name) { + stringResource(R.string.liked) -> R.drawable.favorite_border + stringResource(R.string.offline) -> R.drawable.offline + stringResource(R.string.cached_playlist) -> R.drawable.cached + stringResource(R.string.filter_local) -> R.drawable.folder + else -> { + if (autoPlaylist) { + R.drawable.trending_up + } else { + R.drawable.queue_music + + } + } + } + when (playlist.thumbnails.size) { + 0 -> + Box( + modifier = + Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + Icon( + painter = painterResource(painter), + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize / 2) + .align(Alignment.Center) + ) + } + + 1 -> + AsyncImage( + model = playlist.thumbnails[0], + contentDescription = null, + modifier = + Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + + else -> + Box( + modifier = + Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd, + ).fastForEachIndexed { index, alignment -> + AsyncImage( + model = playlist.thumbnails.getOrNull(index), + contentDescription = null, + modifier = + Modifier + .align(alignment) + .size(ListThumbnailSize / 2), + ) + } + } + } + }, + trailingContent = trailingContent, + modifier = modifier, +) + +@Composable +fun PlaylistGridItem( + playlist: Playlist, + modifier: Modifier = Modifier, + badges: @Composable RowScope.() -> Unit = { }, + fillMaxWidth: Boolean = false, + autoPlaylist: Boolean = false, + context: Context // Agregamos el contexto para obtener la URI de la imagen +) = GridItem( + title = playlist.playlist.name, + subtitle = if (autoPlaylist) { + "" + } else { + if (playlist.songCount == 0 && playlist.playlist.remoteSongCount != null) { + pluralStringResource( + R.plurals.n_song, + playlist.playlist.remoteSongCount, + playlist.playlist.remoteSongCount + ) + } else { + pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount) + } + }, + badges = badges, + thumbnailContent = { + val thumbnailUri = + getPlaylistImageUri(context, playlist.playlist.id) // Obtener URI de la miniatura + + if (thumbnailUri != null) { + // Si la URI de la imagen existe, la mostramos + AsyncImage( + model = thumbnailUri, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + } else { + // Si no hay miniatura, mostrar la imagen predeterminada + val painter = + when (playlist.playlist.name) { + stringResource(R.string.liked) -> R.drawable.favorite_border + stringResource(R.string.offline) -> R.drawable.offline + stringResource(R.string.cached_playlist) -> R.drawable.cached + stringResource(R.string.filter_local) -> R.drawable.folder + else -> { + if (autoPlaylist) { + R.drawable.trending_up + } else { + R.drawable.queue_music + } + } + } + val width = maxWidth + val Libcarditem = 25.dp + when (playlist.thumbnails.size) { + 0 -> + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .clip(RoundedCornerShape(Libcarditem)) + ) { + Icon( + painter = painterResource(painter), + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = 0.8f), + modifier = + Modifier + .size(width / 2) + .align(Alignment.Center), + ) + } + + 1 -> + AsyncImage( + model = playlist.thumbnails[0], + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + + else -> + Box( + modifier = + Modifier + .size(width) + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd, + ).fastForEachIndexed { index, alignment -> + AsyncImage( + model = playlist.thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = + Modifier + .align(alignment) + .size(width / 2), + ) + } + } + } + } + }, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth = fillMaxWidth, + modifier = modifier, +) + + +@Composable +fun MediaMetadataListItem( + mediaMetadata: MediaMetadata, + modifier: Modifier, + isSelected: Boolean = false, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = mediaMetadata.title, + subtitle = + joinByBullet( + mediaMetadata.artists.joinToString { it.name }, + makeTimeString(mediaMetadata.duration * 1000L), + ), + thumbnailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(ListThumbnailSize), + ) { + if (isSelected) { + Box( + modifier = + Modifier + .fillMaxSize() + .zIndex(1000f) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(Color.Black.copy(alpha = 0.5f)), + ) { + Icon( + painter = painterResource(R.drawable.done), + modifier = Modifier.align(Alignment.Center), + contentDescription = null, + ) + } + } + + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + color = Color.White, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) + } + }, + trailingContent = trailingContent, + modifier = modifier, + isActive = isActive, +) + +@Composable +fun YouTubeListItem( + item: YTItem, + modifier: Modifier = Modifier, + albumIndex: Int? = null, + isSelected: Boolean = false, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val song by database.song(item.id).collectAsState(initial = null) + val album by database.album(item.id).collectAsState(initial = null) + + if (item is SongItem && + song?.song?.liked == true || + item is AlbumItem && + album?.album?.bookmarkedAt != null + ) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + if (item is SongItem && song?.song?.inLibrary != null) { + Icon( + painter = painterResource(R.drawable.library_add_check), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + if (item is SongItem) { + val downloads by LocalDownloadUtil.current.downloads.collectAsState() + when (downloads[item.id]?.state) { + STATE_COMPLETED -> + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + + STATE_QUEUED, STATE_DOWNLOADING -> + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = + Modifier + .size(16.dp) + .padding(end = 2.dp), + ) + + else -> {} + } + } + }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = item.title, + subtitle = + when (item) { + is SongItem -> joinByBullet( + item.artists.joinToString { it.name }, + makeTimeString(item.duration?.times(1000L)) + ) + + is AlbumItem -> joinByBullet( + item.artists?.joinToString { it.name }, + item.year?.toString() + ) + + is ArtistItem -> null + is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) + }, + badges = badges, + thumbnailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(ListThumbnailSize), + ) { + val thumbnailShape = + if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius) + if (albumIndex != null) { + AnimatedVisibility( + visible = !isActive, + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut(), + ) { + if (isSelected) { + Icon( + painter = painterResource(R.drawable.done), + modifier = Modifier.align(Alignment.Center), + contentDescription = null, + ) + } else { + Text( + text = albumIndex.toString(), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } else { + if (isSelected) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .zIndex(1000f) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(Color.Black.copy(alpha = 0.5f)), + ) { + Icon( + painter = painterResource(R.drawable.done), + modifier = Modifier.align(Alignment.Center), + contentDescription = null, + ) + } + } + AsyncImage( + model = item.thumbnail, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .clip(thumbnailShape), + ) + } + + PlayingIndicatorBox( + isActive = isActive, + playWhenReady = isPlaying, + color = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White, + modifier = + Modifier + .fillMaxSize() + .background( + color = + if (albumIndex != null) { + Color.Transparent + } else { + Color.Black.copy( + alpha = 0.4f, + ) + }, + shape = thumbnailShape, + ), + ) + } + }, + trailingContent = trailingContent, + modifier = modifier, + isActive = isActive, +) + +@SuppressLint("SuspiciousIndentation") +@Composable +fun YouTubeGridItem( + item: YTItem, + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope? = null, + badges: @Composable RowScope.() -> Unit = { + val database = LocalDatabase.current + val song by database.song(item.id).collectAsState(initial = null) + val album by database.album(item.id).collectAsState(initial = null) + + if (item is SongItem && + song?.song?.liked == true || + item is AlbumItem && + album?.album?.bookmarkedAt != null + ) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + if (item is SongItem && song?.song?.inLibrary != null) { + Icon( + painter = painterResource(R.drawable.library_add_check), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.explicit), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + } + if (item is SongItem) { + val downloads by LocalDownloadUtil.current.downloads.collectAsState() + when (downloads[item.id]?.state) { + STATE_COMPLETED -> + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .padding(end = 2.dp), + ) + + STATE_DOWNLOADING -> + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = + Modifier + .size(16.dp) + .padding(end = 2.dp), + ) + + else -> {} + } + } + }, + thumbnailRatio: Float = if (item is SongItem) 16f / 9 else 1f, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, +) { + val thumbnailShape = + if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius) + val thumbnailRatio = thumbnailRatio + + Column( + modifier = + if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight * thumbnailRatio) + }, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .aspectRatio(thumbnailRatio) + .clip(thumbnailShape), + ) { + AsyncImage( + model = item.thumbnail, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + + androidx.compose.animation.AnimatedVisibility( + visible = item is AlbumItem && !isActive, + enter = fadeIn(), + exit = fadeOut(), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(8.dp), + ) { + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return@AnimatedVisibility + + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.4f)) + .clickable { + var playlistId = "" + coroutineScope?.launch(Dispatchers.IO) { + var albumWithSongs = database.albumWithSongs(item.id).first() + if (albumWithSongs?.songs.isNullOrEmpty()) { + YouTube + .album(item.id) + .onSuccess { albumPage -> + playlistId = albumPage.album.playlistId + database.transaction { + insert(albumPage) + } + albumWithSongs = + database.albumWithSongs(item.id).first() + }.onFailure { + reportException(it) + } + } + albumWithSongs?.let { + withContext(Dispatchers.Main) { + playerConnection.service.getAutomix(playlistId) + playerConnection.playQueue( + LocalAlbumRadio(it), + ) + } + } + } + }, + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + + androidx.compose.animation.AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = if (isPlaying) 0.4f else 0f), + shape = thumbnailShape, + ), + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp), + ) + } + } + } + + androidx.compose.animation.AnimatedVisibility( + visible = item is SongItem && !(isActive && isPlaying), + enter = fadeIn(), + exit = fadeOut(), + modifier = + Modifier + .align(Alignment.Center) + .padding(8.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.6f)), + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = if (item is ArtistItem) TextAlign.Center else TextAlign.Start, + modifier = + Modifier + .basicMarquee(iterations = 3) + .fillMaxWidth(), + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + badges() + + val subtitle = + when (item) { + is SongItem -> joinByBullet( + item.artists.joinToString { it.name }, + makeTimeString(item.duration?.times(1000L)) + ) + + is AlbumItem -> joinByBullet( + item.artists?.joinToString { it.name }, + item.year?.toString() + ) + + is ArtistItem -> null + is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) + } + + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +fun YouTubeSmallGridItem( + item: YTItem, + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope? = null, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, +) = SmallGridItem( + title = item.title, + thumbnailContent = { + AsyncImage( + model = item.thumbnail, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + if (item is SongItem) { + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = if (isPlaying) 0.4f else 0f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp), + ) + } + } + } + + AnimatedVisibility( + visible = !(isActive && isPlaying), + enter = fadeIn(), + exit = fadeOut(), + modifier = + Modifier + .align(Alignment.Center) + .padding(8.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.6f)), + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + } + }, + thumbnailShape = + when (item) { + is ArtistItem -> CircleShape + else -> RoundedCornerShape(ThumbnailCornerRadius) + }, + modifier = modifier, + isArtist = + when (item) { + is ArtistItem -> true + else -> false + }, +) + +@Composable +fun LocalSongsGrid( + title: String, + subtitle: String, + badges: + @Composable() + (RowScope.() -> Unit) = {}, + thumbnailUrl: String?, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, + modifier: Modifier, +) = GridItem( + title = title, + subtitle = subtitle, + badges = badges, + thumbnailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier.fillMaxWidth(), + ) + + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp), + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + } + + AnimatedVisibility( + visible = !(isActive && isPlaying), + enter = fadeIn(), + exit = fadeOut(), + modifier = + Modifier + .align(Alignment.Center) + .padding(8.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.6f)), + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + } + }, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth = fillMaxWidth, + modifier = modifier, +) + +@Composable +fun LocalArtistsGrid( + title: String, + subtitle: String, + badges: + @Composable() + (RowScope.() -> Unit) = {}, + thumbnailUrl: String?, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, + modifier: Modifier, +) = GridItem( + title = title, + subtitle = subtitle, + badges = badges, + thumbnailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + ) { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = CircleShape, + ), + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp), + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + } + } + }, + thumbnailShape = CircleShape, + fillMaxWidth = fillMaxWidth, + modifier = modifier, +) + +@Composable +fun LocalAlbumsGrid( + title: String, + subtitle: String, + badges: + @Composable() + (RowScope.() -> Unit) = {}, + thumbnailUrl: String?, + isActive: Boolean = false, + isPlaying: Boolean = false, + fillMaxWidth: Boolean = false, + modifier: Modifier, +) = GridItem( + title = title, + subtitle = subtitle, + badges = badges, + thumbnailContent = { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) { + if (isPlaying) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp), + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = Color.White, + ) + } + } + } + }, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth = fillMaxWidth, + modifier = modifier, +) \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/Library.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Library.kt new file mode 100644 index 00000000..12815830 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Library.kt @@ -0,0 +1,288 @@ +package com.darkxvenom.airbeats.ui.component + +import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.navigation.NavController +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.db.entities.Artist +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.ui.menu.AlbumMenu +import com.darkxvenom.airbeats.ui.menu.ArtistMenu +import com.darkxvenom.airbeats.ui.menu.PlaylistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubePlaylistMenu +import kotlinx.coroutines.CoroutineScope + +@Composable +fun LibraryArtistListItem( + navController: NavController, + menuState: MenuState, + coroutineScope: CoroutineScope, + artist: Artist, + modifier: Modifier = Modifier +) = ArtistListItem( + artist = artist, + trailingContent = { + androidx.compose.material3.IconButton( + onClick = { + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = modifier + .fillMaxWidth() + .clickable { + navController.navigate("artist/${artist.id}") + } +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryArtistGridItem( + navController: NavController, + menuState: MenuState, + coroutineScope: CoroutineScope, + artist: Artist, + modifier: Modifier = Modifier +) = ArtistGridItem( + artist = artist, + fillMaxWidth = true, + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("artist/${artist.id}") + }, + onLongClick = { + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) +) + +@Composable +fun LibraryAlbumListItem( + modifier: Modifier = Modifier, + navController: NavController, + menuState: MenuState, + album: Album, + isActive: Boolean = false, + isPlaying: Boolean = false +) = AlbumListItem( + album = album, + isActive = isActive, + isPlaying = isPlaying, + trailingContent = { + androidx.compose.material3.IconButton( + onClick = { + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = modifier + .fillMaxWidth() + .clickable { + navController.navigate("album/${album.id}") + } +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryAlbumGridItem( + modifier: Modifier = Modifier, + navController: NavController, + menuState: MenuState, + coroutineScope: CoroutineScope, + album: Album, + isActive: Boolean = false, + isPlaying: Boolean = false +) = AlbumGridItem( + album = album, + isActive = isActive, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + fillMaxWidth = true, + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) +) + +@Composable +fun LibraryPlaylistListItem( + navController: NavController, + menuState: MenuState, + coroutineScope: CoroutineScope, + playlist: Playlist, + modifier: Modifier = Modifier +) = PlaylistListItem( + playlist = playlist, + trailingContent = { + androidx.compose.material3.IconButton( + onClick = { + menuState.show { + if (playlist.playlist.isEditable || playlist.songCount != 0) { + PlaylistMenu( + playlist = playlist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } else { + playlist.playlist.browseId?.let { browseId -> + YouTubePlaylistMenu( + playlist = PlaylistItem( + id = browseId, + title = playlist.playlist.name, + author = null, + songCountText = null, + thumbnail = playlist.thumbnails.getOrNull(0) ?: "", + playEndpoint = WatchEndpoint( + playlistId = browseId, + params = playlist.playlist.playEndpointParams + ), + shuffleEndpoint = WatchEndpoint( + playlistId = browseId, + params = playlist.playlist.shuffleEndpointParams + ), + radioEndpoint = WatchEndpoint( + playlistId = "RDAMPL$browseId", + params = playlist.playlist.radioEndpointParams + ), + isEditable = false + ), + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = modifier + .fillMaxWidth() + .clickable { + if (!playlist.playlist.isEditable && playlist.songCount == 0 && playlist.playlist.remoteSongCount != 0) + navController.navigate("online_playlist/${playlist.playlist.browseId}") + else + navController.navigate("local_playlist/${playlist.id}") + } +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryPlaylistGridItem( + navController: NavController, + menuState: MenuState, + coroutineScope: CoroutineScope, + playlist: Playlist, + modifier: Modifier = Modifier, + context: Context // Agregamos el contexto para obtener la URI de la miniatura +) = PlaylistGridItem( + playlist = playlist, + fillMaxWidth = true, + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!playlist.playlist.isEditable && playlist.songCount == 0 && playlist.playlist.remoteSongCount != 0) + navController.navigate("online_playlist/${playlist.playlist.browseId}") + else + navController.navigate("local_playlist/${playlist.id}") + }, + onLongClick = { + menuState.show { + if (playlist.playlist.isEditable || playlist.songCount != 0) { + PlaylistMenu( + playlist = playlist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } else { + playlist.playlist.browseId?.let { browseId -> + YouTubePlaylistMenu( + playlist = PlaylistItem( + id = browseId, + title = playlist.playlist.name, + author = null, + songCountText = null, + thumbnail = playlist.thumbnails.getOrNull(0) ?: "", + playEndpoint = WatchEndpoint( + playlistId = browseId, + params = playlist.playlist.playEndpointParams + ), + shuffleEndpoint = WatchEndpoint( + playlistId = browseId, + params = playlist.playlist.shuffleEndpointParams + ), + radioEndpoint = WatchEndpoint( + playlistId = "RDAMPL$browseId", + params = playlist.playlist.radioEndpointParams + ), + isEditable = false + ), + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + } + ), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura +) + diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/LocaleManager.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/LocaleManager.kt new file mode 100644 index 00000000..57c6d0f6 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/LocaleManager.kt @@ -0,0 +1,1068 @@ +@file:Suppress("DEPRECATION") + +package com.darkxvenom.airbeats.ui.component + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.content.res.Resources +import android.os.Build +import android.os.Handler +import android.os.LocaleList +import android.os.Looper +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +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.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.navigationBarsPadding +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.lazy.rememberLazyListState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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 +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.os.ConfigurationCompat +import androidx.core.os.LocaleListCompat +import com.darkxvenom.airbeats.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Locale +import timber.log.Timber + +/** Modelo de datos para representar un idioma */ +data class LanguageItem( + val code: String, + val displayName: String, + val nativeName: String, + val completionStatus: CompletionStatus = CompletionStatus.COMPLETE, + val isSystemDefault: Boolean = false, + val flag: String = "" +) + +/** Estado de completitud de las traducciones */ +enum class CompletionStatus(val label: String, val color: @Composable () -> Color) { + COMPLETE("", { Color.Transparent }), + INCOMPLETE("Incompleta", { MaterialTheme.colorScheme.tertiary }), + BETA("Beta", { MaterialTheme.colorScheme.primary }), + EXPERIMENTAL("Exp", { MaterialTheme.colorScheme.secondary }) +} + +/** Estados de la operación de cambio de idioma */ +sealed class LanguageChangeState { + object Idle : LanguageChangeState() + object Changing : LanguageChangeState() + object Success : LanguageChangeState() + data class Error(val message: String) : LanguageChangeState() +} +class LocaleManager private constructor(private val context: Context) { + + companion object { + private const val TAG = "LocaleManager" + private const val PREF_NAME = "locale_preferences" + private const val PREF_LANGUAGE_KEY = "selected_language" + private const val SYSTEM_DEFAULT = "system_default" + private const val RESTART_DELAY = 800L + private const val ANIMATION_DELAY = 200L + + @Volatile + private var instance: LocaleManager? = null + + fun getInstance(context: Context): LocaleManager { + return instance ?: synchronized(this) { + instance ?: LocaleManager(context.applicationContext).also { instance = it } + } + } + + // Mapeo de banderas y estados de traducción + private val LANGUAGE_METADATA = mapOf( + "en" to LanguageMetadata("🇺🇸", CompletionStatus.COMPLETE), + "es" to LanguageMetadata("🇪🇸", CompletionStatus.COMPLETE), + "fr" to LanguageMetadata("🇫🇷", CompletionStatus.COMPLETE), + "de" to LanguageMetadata("🇩🇪", CompletionStatus.COMPLETE), + "it" to LanguageMetadata("🇮🇹", CompletionStatus.COMPLETE), + "pt-rBR" to LanguageMetadata("🇧🇷", CompletionStatus.COMPLETE), + "pt" to LanguageMetadata("🇵🇹", CompletionStatus.COMPLETE), + "ru" to LanguageMetadata("🇷🇺", CompletionStatus.COMPLETE), + "zh-rCN" to LanguageMetadata("🇨🇳", CompletionStatus.COMPLETE), + "zh-rTW" to LanguageMetadata("🇹🇼", CompletionStatus.COMPLETE), + "ja" to LanguageMetadata("🇯🇵", CompletionStatus.COMPLETE), + "ko" to LanguageMetadata("🇰🇷", CompletionStatus.COMPLETE), + "ar" to LanguageMetadata("🇸🇦", CompletionStatus.BETA), + "hi" to LanguageMetadata("🇮🇳", CompletionStatus.BETA), + "th" to LanguageMetadata("🇹🇭", CompletionStatus.INCOMPLETE), + "vi" to LanguageMetadata("🇻🇳", CompletionStatus.INCOMPLETE), + "tr" to LanguageMetadata("🇹🇷", CompletionStatus.BETA), + "pl" to LanguageMetadata("🇵🇱", CompletionStatus.INCOMPLETE), + "nl" to LanguageMetadata("🇳🇱", CompletionStatus.INCOMPLETE), + "id" to LanguageMetadata("🇮🇩", CompletionStatus.BETA), + "uk" to LanguageMetadata("🇺🇦", CompletionStatus.BETA), + "he" to LanguageMetadata("🇮🇱", CompletionStatus.BETA) + ) + + private data class LanguageMetadata( + val flag: String, + val completionStatus: CompletionStatus + ) + } + + private val sharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + private val _currentLanguage = MutableStateFlow(getSelectedLanguageCode()) + private val _changeState = MutableStateFlow(LanguageChangeState.Idle) + + val currentLanguage: StateFlow = _currentLanguage.asStateFlow() + val changeState: StateFlow = _changeState.asStateFlow() + + private var _cachedLanguages: List? = null + private var _cachedSystemLanguage: String? = null + + fun getSelectedLanguageCode(): String { + return sharedPreferences.getString(PREF_LANGUAGE_KEY, SYSTEM_DEFAULT) ?: SYSTEM_DEFAULT + } + + fun getEffectiveLanguageCode(): String { + val saved = getSelectedLanguageCode() + return if (saved == SYSTEM_DEFAULT) getSystemLanguageCode() else saved + } + + private fun getSystemLanguageCode(): String { + return _cachedSystemLanguage ?: run { + val systemCode = try { + val localeList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + ConfigurationCompat.getLocales(Resources.getSystem().configuration) + } else { + LocaleListCompat.create(Locale.getDefault()) + } + + val systemLocale = if (localeList.isEmpty) Locale.getDefault() else localeList[0] + ?: Locale.getDefault() + + formatLocaleCode(systemLocale) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error obteniendo idioma del sistema") + "en" + } + _cachedSystemLanguage = systemCode + systemCode + } + } + private fun detectAvailableLanguages(): List { + val availableLocales = mutableSetOf() + + try { + val assetManager = context.assets + val locales = assetManager.locales + + locales.forEach { localeString -> + if (localeString.isNotEmpty()) { + availableLocales.add(localeString) + } + } + + if (availableLocales.isEmpty()) { + val pm = context.packageManager + val res = pm.getResourcesForApplication(context.packageName) + + // Intentar detectar mediante configuraciones disponibles + val configs = res.assets.locales + configs.forEach { locale -> + if (locale.isNotEmpty()) { + availableLocales.add(locale) + } + } + } + + if (availableLocales.isEmpty()) { + val commonLocales = listOf( + "en", "es", "fr", "de", "it", "pt", "pt-rBR", + "ru", "zh-rCN", "zh-rTW", "ja", "ko", "ar", + "hi", "th", "vi", "tr", "pl", "nl", "id", "uk", "he" + ) + + commonLocales.forEach { localeCode -> + if (hasTranslationsForLocale(localeCode)) { + availableLocales.add(localeCode) + } + } + } + + Timber.tag(TAG).d("Idiomas detectados: $availableLocales") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error detectando idiomas disponibles") + // Fallback al idioma por defecto + availableLocales.add("en") + } + + return availableLocales.toList() + } + + /** + * Verifica si existen traducciones para un locale específico + */ + private fun hasTranslationsForLocale(localeCode: String): Boolean { + return try { + val locale = parseLocaleCode(localeCode) + val config = Configuration(context.resources.configuration) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + config.setLocale(locale) + } else { + config.locale = locale + } + + val localizedContext = context.createConfigurationContext(config) + val localizedResources = localizedContext.resources + + // Intentar obtener un string básico para verificar + try { + val appName = localizedResources.getString(R.string.app_name) + true + } catch (e: Resources.NotFoundException) { + false + } + } catch (e: Exception) { + false + } + } + + + private fun formatLocaleCode(locale: Locale): String { + val language = locale.language + val country = locale.country + + return when { + language == "zh" && country.isNotEmpty() -> { + when (country) { + "CN" -> "zh-rCN" + "TW", "HK" -> "zh-rTW" + else -> "zh-rCN" + } + } + language == "pt" && country == "BR" -> "pt-rBR" + country.isNotEmpty() -> "$language-r$country" + else -> language + } + } + + /** + * Convierte código de locale a Locale + */ + private fun parseLocaleCode(code: String): Locale { + return when { + code == "zh-rCN" || code == "zh-CN" -> Locale.SIMPLIFIED_CHINESE + code == "zh-rTW" || code == "zh-TW" -> Locale.TRADITIONAL_CHINESE + code.contains("-r") -> { + val parts = code.split("-r") + Locale(parts[0], parts[1]) + } + code.contains("-") -> { + val parts = code.split("-") + Locale(parts[0], parts[1]) + } + else -> Locale(code) + } + } + + fun getAvailableLanguages(): List { + return _cachedLanguages ?: run { + val systemLanguageCode = getSystemLanguageCode() + val availableLocaleCodes = detectAvailableLanguages() + + val languages = mutableListOf() + + // Agregar opción de sistema + val systemDisplayName = try { + val locale = parseLocaleCode(systemLanguageCode) + locale.displayLanguage.replaceFirstChar { it.uppercase() } + } catch (e: Exception) { + systemLanguageCode + } + + languages.add( + LanguageItem( + code = SYSTEM_DEFAULT, + displayName = "Sistema ($systemDisplayName)", + nativeName = systemDisplayName, + completionStatus = CompletionStatus.COMPLETE, + isSystemDefault = true, + flag = "🌐" + ) + ) + + // Agregar idiomas detectados + availableLocaleCodes.forEach { localeCode -> + try { + val locale = parseLocaleCode(localeCode) + val displayName = locale.getDisplayLanguage(Locale.ENGLISH) + .replaceFirstChar { it.uppercase() } + val nativeName = locale.getDisplayLanguage(locale) + .replaceFirstChar { it.uppercase() } + + // Obtener metadata (bandera y estado) + val metadata = LANGUAGE_METADATA[localeCode] + ?: LanguageMetadata("🌍", CompletionStatus.COMPLETE) + + languages.add( + LanguageItem( + code = localeCode, + displayName = displayName, + nativeName = nativeName, + completionStatus = metadata.completionStatus, + isSystemDefault = false, + flag = metadata.flag + ) + ) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error procesando locale: $localeCode") + } + } + + // Ordenar por: sistema primero, luego completos, luego alfabéticamente + val sorted = languages.sortedWith( + compareBy { !it.isSystemDefault } + .thenBy { it.completionStatus.ordinal } + .thenBy { it.displayName } + ) + + _cachedLanguages = sorted + sorted + } + } + + suspend fun updateLanguage(languageCode: String): Boolean { + if (_changeState.value is LanguageChangeState.Changing) { + return false + } + + return try { + _changeState.value = LanguageChangeState.Changing + Timber.tag(TAG).d("Cambiando idioma a: $languageCode") + + delay(ANIMATION_DELAY) + + val editor = sharedPreferences.edit() + editor.putString(PREF_LANGUAGE_KEY, languageCode) + val saved = editor.commit() + + if (!saved) { + throw Exception("No se pudo guardar la preferencia") + } + + _currentLanguage.value = languageCode + + val effectiveLanguageCode = if (languageCode == SYSTEM_DEFAULT) { + getSystemLanguageCode() + } else { + languageCode + } + + val locale = parseLocaleCode(effectiveLanguageCode) + applyLocaleToApp(locale) + + _changeState.value = LanguageChangeState.Success + + Timber.tag(TAG) + .d("Idioma actualizado: $languageCode (efectivo: $effectiveLanguageCode)") + true + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error actualizando idioma a $languageCode") + _changeState.value = LanguageChangeState.Error(e.message ?: "Error desconocido") + false + } + } + + fun clearCache() { + _cachedLanguages = null + _cachedSystemLanguage = null + } + + private fun applyLocaleToApp(locale: Locale) { + try { + Locale.setDefault(locale) + val config = Configuration(context.resources.configuration) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val localeList = LocaleList(locale) + LocaleList.setDefault(localeList) + config.setLocales(localeList) + config.setLocale(locale) + } else { + config.locale = locale + } + + @Suppress("DEPRECATION") + context.resources.updateConfiguration(config, context.resources.displayMetrics) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error aplicando configuración de idioma") + } + } + + fun applyLocaleToContext(baseContext: Context): Context { + return try { + val languageCode = getEffectiveLanguageCode() + val locale = parseLocaleCode(languageCode) + + Locale.setDefault(locale) + val config = Configuration(baseContext.resources.configuration) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + config.setLocale(locale) + val localeList = LocaleList(locale) + LocaleList.setDefault(localeList) + config.setLocales(localeList) + baseContext.createConfigurationContext(config) + } else { + config.locale = locale + @Suppress("DEPRECATION") + baseContext.resources.updateConfiguration( + config, + baseContext.resources.displayMetrics + ) + baseContext + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error aplicando idioma al contexto") + baseContext + } + } + + fun restartApp(context: Context) { + try { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + intent?.let { + it.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + Handler(Looper.getMainLooper()).postDelayed({ + context.startActivity(it) + if (context is Activity) { + context.finish() + context.overridePendingTransition( + android.R.anim.fade_in, + android.R.anim.fade_out + ) + } + }, RESTART_DELAY) + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error reiniciando aplicación") + } + } + + fun resetChangeState() { + _changeState.value = LanguageChangeState.Idle + } +} + +// Los composables permanecen igual... +// (LanguageSelector, SearchBar, ChangeStateIndicator, etc.) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LanguageSelector( + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {} +) { + val context = LocalContext.current + val localeManager = remember { LocaleManager.getInstance(context) } + val hapticFeedback = LocalHapticFeedback.current + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + val currentLanguage by localeManager.currentLanguage.collectAsState() + val changeState by localeManager.changeState.collectAsState() + val availableLanguages by remember { derivedStateOf { localeManager.getAvailableLanguages() } } + + var selectedLanguageCode by remember { mutableStateOf(null) } + var searchQuery by remember { mutableStateOf("") } + val listState = rememberLazyListState() + val focusRequester = remember { FocusRequester() } + + val filteredLanguages = remember(availableLanguages, searchQuery) { + if (searchQuery.isBlank()) { + availableLanguages + } else { + availableLanguages.filter { language -> + language.displayName.contains(searchQuery, ignoreCase = true) || + language.nativeName.contains(searchQuery, ignoreCase = true) || + language.code.contains(searchQuery, ignoreCase = true) + } + } + } + + LaunchedEffect(selectedLanguageCode) { + selectedLanguageCode?.let { languageCode -> + if (localeManager.updateLanguage(languageCode)) { + localeManager.restartApp(context) + } + selectedLanguageCode = null + } + } + + LaunchedEffect(filteredLanguages, currentLanguage) { + val selectedIndex = filteredLanguages.indexOfFirst { it.code == currentLanguage } + if (selectedIndex != -1) { + listState.animateScrollToItem(selectedIndex) + } + } + + DisposableEffect(Unit) { + onDispose { + localeManager.resetChangeState() + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = modifier, + dragHandle = { + Surface( + modifier = Modifier + .padding(vertical = 8.dp) + .size(width = 32.dp, height = 4.dp) + .clip(RoundedCornerShape(2.dp)), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) {} + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + ) { + Text( + text = stringResource(R.string.language), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) + ) + + SearchBar( + query = searchQuery, + onQueryChange = { searchQuery = it }, + onClear = { + searchQuery = "" + focusManager.clearFocus() + keyboardController?.hide() + }, + focusRequester = focusRequester, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp) + ) + + AnimatedVisibility( + visible = changeState is LanguageChangeState.Changing || + changeState is LanguageChangeState.Success, + enter = fadeIn(), + exit = fadeOut() + ) { + ChangeStateIndicator( + isChanging = changeState is LanguageChangeState.Changing + ) + } + + if (filteredLanguages.isEmpty()) { + EmptySearchResult(modifier = Modifier.padding(vertical = 32.dp)) + } else { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .selectableGroup(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + items( + items = filteredLanguages, + key = { it.code } + ) { language -> + val isSelected = language.code == currentLanguage + val isEnabled = changeState !is LanguageChangeState.Changing + + LanguageItem( + language = language, + isSelected = isSelected, + isEnabled = isEnabled, + onClick = { + if (isEnabled && !isSelected) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + keyboardController?.hide() + focusManager.clearFocus() + selectedLanguageCode = language.code + } + } + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + onClear: () -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + val focusManager = LocalFocusManager.current + + Surface( + modifier = modifier, + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 1.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { focusManager.clearFocus() } + ), + decorationBox = { innerTextField -> + if (query.isEmpty()) { + Text( + text = "Buscar idioma...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + innerTextField() + } + ) + + AnimatedVisibility( + visible = query.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut() + ) { + IconButton( + onClick = onClear, + modifier = Modifier.size(20.dp) + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Limpiar búsqueda", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + } + } +} + +@Composable +private fun ChangeStateIndicator( + isChanging: Boolean +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (isChanging) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Aplicando...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Reiniciando...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun EmptySearchResult( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Sin resultados", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Prueba con otro término", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 4.dp) + ) + } +} + +@Composable +private fun LanguageItem( + language: LanguageItem, + isSelected: Boolean, + isEnabled: Boolean, + onClick: () -> Unit +) { + val elevation by animateDpAsState( + targetValue = if (isSelected) 2.dp else 0.dp, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "elevation" + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .selectable( + selected = isSelected, + enabled = isEnabled, + role = Role.RadioButton, + onClick = onClick + ), + color = if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + tonalElevation = elevation + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Bandera + Text( + text = language.flag, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.size(28.dp), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.width(16.dp)) + + // Información + Column(modifier = Modifier.weight(1f)) { + Text( + text = language.displayName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (language.nativeName.isNotEmpty() && + language.nativeName != language.displayName && + !language.isSystemDefault + ) { + Text( + text = language.nativeName, + style = MaterialTheme.typography.bodySmall, + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Badge de estado + if (language.completionStatus != CompletionStatus.COMPLETE) { + val statusColor = language.completionStatus.color() + + Surface( + shape = RoundedCornerShape(6.dp), + color = statusColor.copy(alpha = 0.12f), + modifier = Modifier.padding(end = 8.dp) + ) { + Text( + text = language.completionStatus.label, + style = MaterialTheme.typography.labelSmall, + color = statusColor, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + + // Radio button + RadioButton( + selected = isSelected, + onClick = null, + enabled = isEnabled, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } +} + +@Composable +fun LanguagePreference( + modifier: Modifier = Modifier +) { + var showLanguageSelector by remember { mutableStateOf(false) } + val context = LocalContext.current + val localeManager = remember { LocaleManager.getInstance(context) } + val currentLanguage by localeManager.currentLanguage.collectAsState() + val changeState by localeManager.changeState.collectAsState() + + val currentLanguageDisplay = remember(currentLanguage) { + val selectedCode = localeManager.getSelectedLanguageCode() + localeManager.getAvailableLanguages() + .find { it.code == selectedCode } + ?.let { language -> + if (language.isSystemDefault) { + language.nativeName + } else { + "${language.nativeName} ${language.flag}".trim() + } + } ?: selectedCode + } + + val isChanging = changeState is LanguageChangeState.Changing + + Card( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(enabled = !isChanging) { + showLanguageSelector = true + }, + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painterResource(R.drawable.translate), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.language), + style = MaterialTheme.typography.titleMedium, + color = if (isChanging) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + fontWeight = FontWeight.Medium + ) + + Text( + text = if (isChanging) { + stringResource(R.string.changing_language) + } else { + currentLanguageDisplay + }, + style = MaterialTheme.typography.bodyMedium, + color = if (isChanging) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(top = 2.dp) + ) + } + + if (isChanging) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } else { + Icon( + imageVector = Icons.Default.ArrowForward, + contentDescription = stringResource(R.string.configure_app_language), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + } + + if (showLanguageSelector) { + LanguageSelector( + onDismiss = { showLanguageSelector = false } + ) + } +} + + +abstract class LocaleAwareApplication : android.app.Application() { + + private val localeManager by lazy { LocaleManager.getInstance(this) } + + override fun attachBaseContext(base: Context) { + try { + val updatedContext = LocaleManager.getInstance(base).applyLocaleToContext(base) + super.attachBaseContext(updatedContext) + } catch (e: Exception) { + Timber.tag("LocaleAwareApplication").e(e, "Error aplicando idioma") + super.attachBaseContext(base) + } + } + + override fun onCreate() { + super.onCreate() + try { + localeManager + Timber.tag("LocaleAwareApplication").d("LocaleManager inicializado") + } catch (e: Exception) { + Timber.tag("LocaleAwareApplication").e(e, "Error inicializando LocaleManager") + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + localeManager.clearCache() + } +} + diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/Lyrics.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Lyrics.kt new file mode 100644 index 00000000..e6ec36ff --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Lyrics.kt @@ -0,0 +1,1734 @@ +package com.darkxvenom.airbeats.ui.component + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.res.Configuration +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +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.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.animateScrollBy +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.isSystemInDarkTheme +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.PaddingValues +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.add +import androidx.compose.foundation.layout.asPaddingValues +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.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +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.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +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.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.Player.STATE_ENDED +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AnimateLyricsKey +import com.darkxvenom.airbeats.constants.DarkModeKey +import com.darkxvenom.airbeats.constants.LyricsClickKey +import com.darkxvenom.airbeats.constants.LyricsScrollKey +import com.darkxvenom.airbeats.constants.LyricsTextPositionKey +import com.darkxvenom.airbeats.constants.PlayerBackgroundStyle +import com.darkxvenom.airbeats.constants.PlayerBackgroundStyleKey +import com.darkxvenom.airbeats.constants.RotateBackgroundKey +import com.darkxvenom.airbeats.constants.ShowLyricsKey +import com.darkxvenom.airbeats.constants.SliderStyle +import com.darkxvenom.airbeats.constants.SliderStyleKey +import com.darkxvenom.airbeats.db.entities.LyricsEntity +import com.darkxvenom.airbeats.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.extensions.toggleRepeatMode +import com.darkxvenom.airbeats.lyrics.LyricsEntry +import com.darkxvenom.airbeats.lyrics.LyricsUtils.findCurrentLineIndex +import com.darkxvenom.airbeats.lyrics.LyricsUtils.parseLyrics +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.component.shimmer.TextPlaceholder +import com.darkxvenom.airbeats.ui.menu.LyricsMenu +import com.darkxvenom.airbeats.ui.screens.settings.DarkMode +import com.darkxvenom.airbeats.ui.screens.settings.LyricsPosition +import com.darkxvenom.airbeats.ui.utils.fadingEdge +import com.darkxvenom.airbeats.utils.makeTimeString +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.saket.squiggles.SquigglySlider +import kotlin.time.Duration.Companion.seconds + +@RequiresApi(Build.VERSION_CODES.M) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@SuppressLint("UnusedBoxWithConstraintsScope", "StringFormatInvalid", + "LocalContextGetResourceValueCall" +) +@Composable +fun Lyrics( + sliderPositionProvider: () -> Long?, + onNavigateBack: (() -> Unit)? = null, + @SuppressLint("ModifierParameter") modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val menuState = LocalMenuState.current + val density = LocalDensity.current + val context = LocalContext.current + val configuration = LocalConfiguration.current + val scope = rememberCoroutineScope() + val database = LocalDatabase.current + + val isFullscreen = onNavigateBack != null + val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.DEFAULT) + val landscapeOffset = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val lyricsTextPosition by rememberEnumPreference(LyricsTextPositionKey, LyricsPosition.CENTER) + val changeLyrics by rememberPreference(LyricsClickKey, true) + val scrollLyrics by rememberPreference(LyricsScrollKey, true) + val animateLyrics by rememberPreference(AnimateLyricsKey, true) + + var showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) + + val infiniteTransition = rememberInfiniteTransition(label = "backgroundRotation") + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 20000, // 20 seconds per complete rotation + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + + val rotateBackground by rememberPreference(RotateBackgroundKey, defaultValue = false) + + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val currentSongId = mediaMetadata?.id + + var currentLineIndex by remember { mutableIntStateOf(-1) } + var deferredCurrentLineIndex by remember(currentSongId) { mutableIntStateOf(0) } + var previousLineIndex by remember(currentSongId) { mutableIntStateOf(0) } + var lastPreviewTime by remember(currentSongId) { mutableLongStateOf(0L) } + var isSeeking by remember { mutableStateOf(false) } + var initialScrollDone by remember(currentSongId) { mutableStateOf(false) } + var shouldScrollToFirstLine by remember(currentSongId) { mutableStateOf(true) } + var isAppMinimized by rememberSaveable { mutableStateOf(false) } + var sliderPosition by remember { mutableStateOf(null) } + var showImageOverlay by remember { mutableStateOf(false) } + var showControls by remember { mutableStateOf(true) } + var cornerRadius by remember { mutableFloatStateOf(16f) } + + var isAutoScrollEnabled by rememberSaveable { mutableStateOf(true) } + + // Improved selection system + var isSelectionModeActive by remember(currentSongId) { mutableStateOf(false) } + val selectedIndices = remember(currentSongId) { mutableStateListOf() } + var showMaxSelectionToast by remember { mutableStateOf(false) } + + // States for sharing + var showProgressDialog by remember { mutableStateOf(false) } + var showShareDialog by remember { mutableStateOf(false) } + var shareDialogData by remember { mutableStateOf?>(null) } + + val lazyListState = rememberLazyListState() + var isAnimating by remember { mutableStateOf(false) } + val maxSelectionLimit = 5 + + // Optimized cache system + var lyricsCache by remember { mutableStateOf>(emptyMap()) } + var currentLyricsEntity by remember(currentSongId) { + mutableStateOf(lyricsCache[currentSongId]) + } + var isLoadingLyrics by remember(currentSongId) { mutableStateOf(false) } + + val lyricsEntity by playerConnection.currentLyrics.collectAsState(initial = null) + val lyrics = remember(lyricsEntity) { lyricsEntity?.lyrics?.trim() } + + val playbackState by playerConnection.playbackState.collectAsState() + val isPlaying by playerConnection.isPlaying.collectAsState() + val repeatMode by playerConnection.repeatMode.collectAsState() + val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() + val canSkipNext by playerConnection.canSkipNext.collectAsState() + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + + val playerBackground by rememberEnumPreference( + key = PlayerBackgroundStyleKey, + defaultValue = PlayerBackgroundStyle.DEFAULT + ) + + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val isSystemInDarkTheme = isSystemInDarkTheme() + val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + + var position by rememberSaveable(playbackState) { mutableLongStateOf(playerConnection.player.currentPosition) } + var duration by rememberSaveable(playbackState) { mutableLongStateOf(playerConnection.player.duration) } + + val expressiveAccent = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.tertiary + } + val textColor = expressiveAccent + + LaunchedEffect(currentSongId) { + currentSongId?.let { songId -> + if (lyricsCache.containsKey(songId)) { + currentLyricsEntity = lyricsCache[songId] + return@LaunchedEffect + } + + isLoadingLyrics = true + + withContext(Dispatchers.IO) { + try { + val existingLyrics = try { + database.getLyrics(songId) + } catch (e: Throwable) { + null + } + + if (existingLyrics != null) { + val newCache = lyricsCache.toMutableMap().apply { + put(songId, existingLyrics) + } + lyricsCache = newCache + currentLyricsEntity = existingLyrics + } else { + try { + val entryPoint = EntryPointAccessors.fromApplication( + context.applicationContext, + com.darkxvenom.airbeats.di.LyricsHelperEntryPoint::class.java + ) + val lyricsHelper = entryPoint.lyricsHelper() + val fetchedLyrics: String? = mediaMetadata?.let { lyricsHelper.getLyrics(it) } + + val entity = if (!fetchedLyrics.isNullOrBlank()) { + LyricsEntity(songId, fetchedLyrics) + } else { + LyricsEntity(songId, LYRICS_NOT_FOUND) + } + + try { + database.query { + upsert(entity) + } + } catch (e: Throwable) {} + + val newCache = lyricsCache.toMutableMap().apply { + put(songId, entity) + } + lyricsCache = newCache + currentLyricsEntity = entity + } catch (e: Throwable) { + val errorEntity = LyricsEntity(songId, LYRICS_NOT_FOUND) + val newCache = lyricsCache.toMutableMap().apply { + put(songId, errorEntity) + } + lyricsCache = newCache + currentLyricsEntity = errorEntity + } + } + } catch (e: Exception) { + val errorEntity = LyricsEntity(songId, LYRICS_NOT_FOUND) + val newCache = lyricsCache.toMutableMap().apply { + put(songId, errorEntity) + } + lyricsCache = newCache + currentLyricsEntity = errorEntity + } finally { + isLoadingLyrics = false + } + } + } + } + + val lines = remember(lyrics, scope) { + if (lyrics == null || lyrics == LYRICS_NOT_FOUND) { + emptyList() + } else if (lyrics.startsWith("[")) { + val parsedLines = parseLyrics(lyrics) + listOf(LyricsEntry.HEAD_LYRICS_ENTRY) + parsedLines + } else { + lyrics.lines().mapIndexed { index, line -> + LyricsEntry(index * 100L, line) + } + } + } + + LaunchedEffect(lines) { + isSelectionModeActive = false + selectedIndices.clear() + currentLineIndex = -1 + deferredCurrentLineIndex = 0 + previousLineIndex = 0 + initialScrollDone = false + shouldScrollToFirstLine = true + isAutoScrollEnabled = true + } + + val isSynced = remember(lyrics) { + !lyrics.isNullOrEmpty() && lyrics.startsWith("[") + } + + BackHandler(enabled = isSelectionModeActive || isFullscreen) { + when { + isSelectionModeActive -> { + isSelectionModeActive = false + selectedIndices.clear() + } + isFullscreen -> onNavigateBack?.invoke() + } + } + + // Disable auto-scroll cuando el usuario interactúa + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (source == NestedScrollSource.UserInput) { + isAutoScrollEnabled = false + } + if (!isSelectionModeActive) { + lastPreviewTime = System.currentTimeMillis() + } + return super.onPostScroll(consumed, available, source) + } + + override suspend fun onPostFling( + consumed: Velocity, + available: Velocity + ): Velocity { + isAutoScrollEnabled = false + if (!isSelectionModeActive) { + lastPreviewTime = System.currentTimeMillis() + } + return super.onPostFling(consumed, available) + } + } + } + + LaunchedEffect(Unit) { + if (isFullscreen) { + cornerRadius = 16f + } + } + + LaunchedEffect(playbackState) { + if (isFullscreen && playbackState == Player.STATE_READY) { + while (isActive) { + delay(100) + position = playerConnection.player.currentPosition + duration = playerConnection.player.duration + } + } + } + + val imageScale by animateFloatAsState( + targetValue = if (showImageOverlay) 1f else 0.95f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ), + label = "imageScale" + ) + + LaunchedEffect(showMaxSelectionToast) { + if (showMaxSelectionToast) { + Toast.makeText( + context, + context.getString(R.string.max_selection_limit, maxSelectionLimit), + Toast.LENGTH_SHORT + ).show() + showMaxSelectionToast = false + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + val visibleItemsInfo = lazyListState.layoutInfo.visibleItemsInfo + val isCurrentLineVisible = visibleItemsInfo.any { it.index == currentLineIndex } + if (isCurrentLineVisible) { + initialScrollDone = false + } + isAppMinimized = true + } else if (event == Lifecycle.Event.ON_START) { + isAppMinimized = false + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + LaunchedEffect(lyrics) { + if (lyrics.isNullOrEmpty() || !lyrics.startsWith("[")) { + currentLineIndex = -1 + return@LaunchedEffect + } + while (isActive) { + delay(50) + val sliderPos = sliderPositionProvider() + isSeeking = sliderPos != null + currentLineIndex = findCurrentLineIndex( + lines, + sliderPos ?: playerConnection.player.currentPosition + ) + } + } + + LaunchedEffect(isSeeking, lastPreviewTime) { + if (isSeeking) { + lastPreviewTime = 0L + } else if (lastPreviewTime != 0L) { + delay(if (isFullscreen) 2.seconds else LyricsPreviewTime) + lastPreviewTime = 0L + } + } + + suspend fun performSmoothPageScroll(targetIndex: Int, duration: Int = 1500) { + if (isAnimating) return + isAnimating = true + try { + val itemInfo = lazyListState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == targetIndex } + if (itemInfo != null) { + val viewportHeight = lazyListState.layoutInfo.viewportEndOffset - + lazyListState.layoutInfo.viewportStartOffset + val center = lazyListState.layoutInfo.viewportStartOffset + (viewportHeight / 2) + val itemCenter = itemInfo.offset + itemInfo.size / 2 + val offset = itemCenter - center + if (kotlin.math.abs(offset) > 10) { + lazyListState.animateScrollBy( + value = offset.toFloat(), + animationSpec = tween(durationMillis = duration) + ) + } + } else { + lazyListState.scrollToItem(targetIndex) + } + } finally { + isAnimating = false + } + } + + LaunchedEffect(currentLineIndex, lastPreviewTime, initialScrollDone, isAutoScrollEnabled) { + if (!isSynced) return@LaunchedEffect + + if (isAutoScrollEnabled) { + if ((currentLineIndex == 0 && shouldScrollToFirstLine) || !initialScrollDone) { + shouldScrollToFirstLine = false + val initialCenterIndex = kotlin.math.max(0, currentLineIndex) + performSmoothPageScroll(initialCenterIndex, 800) + if (!isAppMinimized) { + initialScrollDone = true + } + } else if (currentLineIndex != -1) { + deferredCurrentLineIndex = currentLineIndex + if (isSeeking) { + val seekCenterIndex = kotlin.math.max(0, currentLineIndex - 1) + performSmoothPageScroll(seekCenterIndex, 500) + } else if ((lastPreviewTime == 0L || currentLineIndex != previousLineIndex) && scrollLyrics) { + if (currentLineIndex != previousLineIndex) { + performSmoothPageScroll(currentLineIndex, 1500) + } + } + } + } + if (currentLineIndex > 0) { + shouldScrollToFirstLine = true + } + previousLineIndex = currentLineIndex + } + + Box( + modifier = modifier + .fillMaxSize() + .background(if (isFullscreen) MaterialTheme.colorScheme.background else Color.Transparent) + .then( + if (isFullscreen) { + Modifier.pointerInput(Unit) { + detectTapGestures( + onTap = { + if (!isSelectionModeActive) { + showControls = !showControls + } + } + ) + } + } else Modifier + ) + ) { + if (isFullscreen) { + mediaMetadata?.let { metadata -> + if (playerBackground == PlayerBackgroundStyle.BLUR) { + AsyncImage( + model = metadata.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(50.dp) + .graphicsLayer { + scaleX = 2.5f + scaleY = 2.5f + if (rotateBackground) { + rotationZ = rotation + transformOrigin = TransformOrigin.Center + } + } + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)) + ) + } + } + + // Header with controls + AnimatedVisibility( + visible = showControls, + enter = fadeIn(tween(300)) + slideInVertically(tween(300)) { -it }, + exit = fadeOut(tween(300)) + slideOutVertically(tween(300)) { -it }, + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .zIndex(2f) + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 1f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { onNavigateBack?.invoke() }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent + ) + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + mediaMetadata?.let { metadata -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = metadata.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = metadata.artists.joinToString { it.name }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Button to clear screen + IconButton( + onClick = { showControls = !showControls }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent + ) + ) { + Icon( + painter = painterResource( + if (showControls) R.drawable.visibility_off else R.drawable.visibility + ), + contentDescription = if (showControls) + stringResource(R.string.hide_controls) + else + stringResource(R.string.show_controls), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + if (isSelectionModeActive) { + IconButton( + onClick = { + if (selectedIndices.isNotEmpty()) { + val sortedIndices = selectedIndices.sorted() + val selectedLyricsText = sortedIndices + .mapNotNull { lines.getOrNull(it)?.text } + .joinToString("\n") + + if (selectedLyricsText.isNotBlank()) { + shareDialogData = Triple( + selectedLyricsText, + mediaMetadata?.title ?: "", + mediaMetadata?.artists?.joinToString { it.name } + ?: "" + ) + showShareDialog = true + } + isSelectionModeActive = false + selectedIndices.clear() + } + }, + enabled = selectedIndices.isNotEmpty(), + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent + ) + ) { + Icon( + painter = painterResource(R.drawable.media3_icon_share), + contentDescription = stringResource(R.string.share_selected), + tint = if (selectedIndices.isNotEmpty()) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + } else { + IconButton( + onClick = { + mediaMetadata?.let { metadata -> + menuState.show { + LyricsMenu( + lyricsProvider = { currentLyricsEntity }, + mediaMetadataProvider = { metadata }, + onDismiss = menuState::dismiss + ) + } + } + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent + ) + ) { + Icon( + painter = painterResource(R.drawable.more_horiz), + contentDescription = stringResource(R.string.more_options), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + + // Playback controls + AnimatedVisibility( + visible = showControls, + enter = fadeIn(tween(300)) + slideInVertically(tween(300)) { it }, + exit = fadeOut(tween(300)) + slideOutVertically(tween(300)) { it }, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .zIndex(2f) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(WindowInsets.systemBars.asPaddingValues()) + .padding(16.dp) + .clip(RoundedCornerShape(20.dp)), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + tonalElevation = 6.dp, + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (sliderStyle) { + SliderStyle.DEFAULT -> { + Slider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { sliderPosition = it.toLong() }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + colors = SliderDefaults.colors( + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.outline.copy( + alpha = 0.3f + ), + thumbColor = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth() + ) + } + + SliderStyle.SQUIGGLY -> { + SquigglySlider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { sliderPosition = it.toLong() }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + colors = SliderDefaults.colors( + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.outline.copy( + alpha = 0.3f + ), + thumbColor = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.fillMaxWidth(), + squigglesSpec = SquigglySlider.SquigglesSpec( + amplitude = if (isPlaying) 2.dp else 0.dp, + strokeWidth = 3.dp + ) + ) + } + + SliderStyle.SLIM -> { + Slider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { sliderPosition = it.toLong() }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + thumb = { Spacer(modifier = Modifier.size(0.dp)) }, + track = { sliderState -> + // PlayerSliderTrack would need to be defined or replaced + SliderDefaults.Track( + sliderState = sliderState, + colors = SliderDefaults.colors( + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.outline.copy( + alpha = 0.3f + ) + ) + ) + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) { + Text( + text = makeTimeString(sliderPosition ?: position), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = if (duration != C.TIME_UNSET) makeTimeString(duration) else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(Modifier.height(16.dp)) + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + onClick = { playerConnection.player.toggleRepeatMode() }, + modifier = Modifier + .size(48.dp) + .alpha(if (repeatMode == Player.REPEAT_MODE_OFF) 0.5f else 1f) + ) { + Icon( + painter = painterResource( + when (repeatMode) { + Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ALL -> R.drawable.repeat + Player.REPEAT_MODE_ONE -> R.drawable.repeat_one + else -> R.drawable.repeat + } + ), + contentDescription = "Repeat", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp) + ) + } + + IconButton( + onClick = { playerConnection.seekToPrevious() }, + enabled = canSkipPrevious, + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(R.drawable.skip_previous), + contentDescription = "Previous", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(32.dp) + ) + } + + FilledTonalIconButton( + onClick = { + if (playbackState == STATE_ENDED) { + playerConnection.player.seekTo(0, 0) + playerConnection.player.playWhenReady = true + } else { + playerConnection.player.togglePlayPause() + } + }, + modifier = Modifier.size(64.dp) + ) { + Icon( + painter = painterResource( + when { + playbackState == STATE_ENDED -> R.drawable.replay + isPlaying -> R.drawable.pause + else -> R.drawable.play + } + ), + contentDescription = if (isPlaying) "Pause" else "Play", + modifier = Modifier.size(32.dp) + ) + } + + IconButton( + onClick = { playerConnection.seekToNext() }, + enabled = canSkipNext, + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(R.drawable.skip_next), + contentDescription = "Next", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(32.dp) + ) + } + + IconButton( + onClick = { playerConnection.toggleLike() }, + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource( + if (currentSong?.song?.liked == true) R.drawable.favorite + else R.drawable.favorite_border + ), + contentDescription = "Like", + tint = if (currentSong?.song?.liked == true) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } + + // Image overlay + AnimatedVisibility( + visible = showImageOverlay, + enter = fadeIn(tween(300)), + exit = fadeOut(tween(300)), + modifier = Modifier + .fillMaxSize() + .zIndex(1f) + ) { + Surface( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(26.dp)) + .pointerInput(Unit) { + detectTapGestures( + onTap = { showImageOverlay = false } + ) + }, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + mediaMetadata?.let { metadata -> + AsyncImage( + model = metadata.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier + .fillMaxWidth(0.85f) + .aspectRatio(1f) + .scale(imageScale) + .clip(RoundedCornerShape(cornerRadius)) + .then( + if (rotateBackground) { + Modifier.graphicsLayer { + rotationZ = rotation + transformOrigin = TransformOrigin.Center + } + } else { + Modifier + } + ) + ) + } + } + } + } + + // Mode selection indicator + AnimatedVisibility( + visible = isSelectionModeActive, + enter = slideInVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium + ) + ) { it } + fadeIn(), + exit = slideOutVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium + ) + ) { it } + fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .zIndex(3f) + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.inverseSurface, + shadowElevation = 6.dp, + tonalElevation = 6.dp, + modifier = Modifier.padding(bottom = if (showControls) 180.dp else 16.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.check_circle), + contentDescription = null, + tint = MaterialTheme.colorScheme.inverseOnSurface, + modifier = Modifier.size(18.dp) + ) + + Text( + text = stringResource( + R.string.selection_mode_active, + selectedIndices.size + ), + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + + BoxWithConstraints( + contentAlignment = if (isFullscreen) Alignment.TopStart else Alignment.Center, + modifier = Modifier + .fillMaxSize() + .then( + if (isFullscreen) { + Modifier.padding( + top = 100.dp, + bottom = if (showControls) 180.dp else 0.dp + ) + } else { + Modifier.padding(bottom = 12.dp) + } + ) + ) { + if (isFullscreen || showLyrics) { + val topPadding = if (isFullscreen) { + with(LocalDensity.current) { + 100.dp + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + } + } else { + 0.dp + } + + LazyColumn( + state = lazyListState, + contentPadding = if (isFullscreen) { + PaddingValues( + top = topPadding, + bottom = if (showControls) 180.dp else 0.dp, + start = 8.dp, + end = 8.dp + ) + } else { + WindowInsets.systemBars + .only(WindowInsetsSides.Top) + .add(WindowInsets(top = maxHeight / 3, bottom = maxHeight / 2)) + .asPaddingValues() + }, + modifier = Modifier + .fadingEdge(vertical = if (isFullscreen) 32.dp else 64.dp) + .nestedScroll(nestedScrollConnection) + ) { + val displayedCurrentLineIndex = + if (!isAutoScrollEnabled || isSeeking || isSelectionModeActive) + deferredCurrentLineIndex + else + currentLineIndex + + if (isLoadingLyrics) { + item { + ShimmerHost { + repeat(if (isFullscreen) 6 else 10) { + Box( + contentAlignment = when (lyricsTextPosition) { + LyricsPosition.LEFT -> Alignment.CenterStart + LyricsPosition.CENTER -> Alignment.Center + LyricsPosition.RIGHT -> Alignment.CenterEnd + }, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = if (isFullscreen) 16.dp else 24.dp, + vertical = if (isFullscreen) 6.dp else 4.dp + ) + ) { + TextPlaceholder() + } + } + } + } + } else { + itemsIndexed( + items = lines, + key = { index, item -> "$index-${item.time}" } + ) { index, item -> + val isSelected = selectedIndices.contains(index) + + val distance = kotlin.math.abs(index - displayedCurrentLineIndex) + val animatedScale by animateFloatAsState(when { + !isSynced || index == displayedCurrentLineIndex -> 1.05f + distance == 1 -> 1f + distance >= 2 -> 0.95f + else -> 1f + }, tween(if (animateLyrics) 400 else 0)) // Aumentada duración + + val animatedAlpha by animateFloatAsState(when { + !isSynced || (isSelectionModeActive && isSelected) -> 1f + index == displayedCurrentLineIndex -> 1f + kotlin.math.abs(index - displayedCurrentLineIndex) == 1 -> 0.7f + kotlin.math.abs(index - displayedCurrentLineIndex) == 2 -> 0.4f + else -> 0.2f + }, tween(if (animateLyrics) 400 else 0)) // Aumentada duración + + val itemModifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .combinedClickable( + enabled = true, + onClick = { + if (isSelectionModeActive) { + if (isSelected) { + selectedIndices.remove(index) + if (selectedIndices.isEmpty()) { + isSelectionModeActive = false + } + } else { + if (selectedIndices.size < maxSelectionLimit) { + selectedIndices.add(index) + } else { + showMaxSelectionToast = true + } + } + } else if (isSynced && changeLyrics) { + playerConnection.player.seekTo(item.time) + scope.launch { + performSmoothPageScroll(index, 1500) // Usar la función mejorada + } + lastPreviewTime = 0L + } + }, + onLongClick = { + if (!isSelectionModeActive) { + isSelectionModeActive = true + selectedIndices.add(index) + } else if (!isSelected && selectedIndices.size < maxSelectionLimit) { + selectedIndices.add(index) + } else if (!isSelected) { + showMaxSelectionToast = true + } + } + ) + .background( + if (isSelected && isSelectionModeActive) + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + else Color.Transparent + ) + .padding( + horizontal = if (isFullscreen) 16.dp else 24.dp, + vertical = 8.dp + ) + .graphicsLayer { + this.alpha = animatedAlpha + this.scaleX = animatedScale + this.scaleY = animatedScale + } + + val isActiveLine = index == displayedCurrentLineIndex && isSynced + + Column( + modifier = itemModifier, + horizontalAlignment = when (lyricsTextPosition) { + LyricsPosition.LEFT -> Alignment.Start + LyricsPosition.CENTER -> Alignment.CenterHorizontally + LyricsPosition.RIGHT -> Alignment.End + } + ) { + if (isActiveLine) { + val fillProgress = remember { Animatable(0f) } + val pulseProgress = remember { Animatable(0f) } + + LaunchedEffect(index) { + fillProgress.snapTo(0f) + fillProgress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 1200, + easing = FastOutSlowInEasing + ) + ) + } + + LaunchedEffect(Unit) { + while (true) { + pulseProgress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 3000, + easing = LinearEasing + ) + ) + pulseProgress.snapTo(0f) + } + } + + val fill = fillProgress.value + val pulse = pulseProgress.value + val pulseEffect = (kotlin.math.sin(pulse * Math.PI.toFloat()) * 0.15f).coerceIn(0f, 0.15f) + val glowIntensity = (fill + pulseEffect).coerceIn(0f, 1.2f) + + val glowBrush = androidx.compose.ui.graphics.Brush.horizontalGradient( + 0.0f to expressiveAccent.copy(alpha = 0.3f), + (fill * 0.7f).coerceIn(0f, 1f) to expressiveAccent.copy(alpha = 0.9f), + fill to expressiveAccent, + (fill + 0.1f).coerceIn(0f, 1f) to expressiveAccent.copy(alpha = 0.7f), + 1.0f to expressiveAccent.copy(alpha = if (fill >= 1f) 1f else 0.3f) + ) + + val styledText = buildAnnotatedString { + withStyle( + style = SpanStyle( + shadow = androidx.compose.ui.graphics.Shadow( + color = expressiveAccent.copy(alpha = 0.8f * glowIntensity), + offset = Offset(0f, 0f), + blurRadius = 28f * (1f + pulseEffect) + ), + brush = glowBrush + ) + ) { + append(item.text) + } + } + + val bounceScale = if (fill < 0.3f) { + 1f + (kotlin.math.sin(fill * 3.33f * Math.PI.toFloat()) * 0.03f) + } else { + 1f + } + + Text( + text = styledText, + fontSize = if (isFullscreen) 25.sp else 24.sp, + textAlign = when (lyricsTextPosition) { + LyricsPosition.LEFT -> TextAlign.Left + LyricsPosition.CENTER -> TextAlign.Center + LyricsPosition.RIGHT -> TextAlign.Right + }, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier + .graphicsLayer { + scaleX = bounceScale + scaleY = bounceScale + } + ) + } else { + // Línea inactiva con color expresivo + val lineColor = if (isFullscreen) { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + } else { + expressiveAccent.copy(alpha = 0.7f) + } + + Text( + text = item.text, + fontSize = if (isFullscreen) 25.sp else 24.sp, + color = animateColorAsState( + targetValue = lineColor, + animationSpec = tween(if (animateLyrics) 250 else 0) + ).value, + textAlign = when (lyricsTextPosition) { + LyricsPosition.LEFT -> TextAlign.Left + LyricsPosition.CENTER -> TextAlign.Center + LyricsPosition.RIGHT -> TextAlign.Right + }, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } + + AnimatedVisibility( + visible = (!isAutoScrollEnabled && isSynced) || isSelectionModeActive, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = if (isFullscreen && showControls) 220.dp else 16.dp) + ) { + Row( + modifier = Modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AnimatedVisibility( + visible = !isAutoScrollEnabled && isSynced, + enter = fadeIn(), + exit = fadeOut() + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + tonalElevation = 4.dp, + modifier = Modifier + .clickable { + scope.launch { + performSmoothPageScroll(currentLineIndex, 1500) + } + isAutoScrollEnabled = true + } + .padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.sync), + contentDescription = stringResource(R.string.auto_scroll), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.auto_scroll), + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + // Selection mode buttons (for floating mode) + AnimatedVisibility( + visible = isSelectionModeActive && !isFullscreen, + enter = fadeIn(), + exit = fadeOut() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Close button + Box( + modifier = Modifier + .size(48.dp) + .background( + color = Color.Black.copy(alpha = 0.3f), + shape = CircleShape + ) + .clickable { + isSelectionModeActive = false + selectedIndices.clear() + }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.close), + contentDescription = stringResource(R.string.cancel), + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + + // Share button + Row( + modifier = Modifier + .background( + color = if (selectedIndices.isNotEmpty()) + Color.White.copy(alpha = 0.9f) + else + Color.White.copy(alpha = 0.5f), + shape = RoundedCornerShape(24.dp) + ) + .clickable(enabled = selectedIndices.isNotEmpty()) { + if (selectedIndices.isNotEmpty()) { + val sortedIndices = selectedIndices.sorted() + val selectedLyricsText = sortedIndices + .mapNotNull { lines.getOrNull(it)?.text } + .joinToString("\n") + + if (selectedLyricsText.isNotBlank()) { + shareDialogData = Triple( + selectedLyricsText, + mediaMetadata?.title ?: "", + mediaMetadata?.artists?.joinToString { it.name } ?: "" + ) + showShareDialog = true + } + isSelectionModeActive = false + selectedIndices.clear() + } + } + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.media3_icon_share), + contentDescription = stringResource(R.string.share_selected), + tint = Color.Black, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.share), + color = Color.Black, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + } + } + } + } + } + + // Lyrics not found + if (lyrics == LYRICS_NOT_FOUND) { + if (isFullscreen) { + Card( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth(0.8f) + .padding(vertical = 32.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.music_note), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(R.string.lyrics_not_found), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Text( + text = "Las letras no están disponibles para esta canción", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } else { + Text( + text = stringResource(R.string.lyrics_not_found), + fontSize = 20.sp, + color = MaterialTheme.colorScheme.secondary, + textAlign = when (lyricsTextPosition) { + LyricsPosition.LEFT -> TextAlign.Left + LyricsPosition.CENTER -> TextAlign.Center + LyricsPosition.RIGHT -> TextAlign.Right + }, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + .alpha(0.5f) + ) + } + } + } + } + + // Buttons for floating mode (not full screen) - SE MANTIENEN IGUAL + if (!isFullscreen) { + mediaMetadata?.let { metadata -> + if (isSelectionModeActive) { + // Selection mode buttons are now shown in the AnimatedVisibility above + // This code block can remain as a fallback or be removed + } else { + // Normal buttons in floating mode + Row( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { showLyrics = !showLyrics } + ) { + Icon( + painter = painterResource(id = R.drawable.close), + contentDescription = null, + tint = textColor + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = { + menuState.show { + LyricsMenu( + lyricsProvider = { currentLyricsEntity }, + mediaMetadataProvider = { metadata }, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(id = R.drawable.more_horiz), + contentDescription = stringResource(R.string.more_options), + tint = textColor + ) + } + } + } + } + } + } + + // Dialogs + if (showProgressDialog) { + BasicAlertDialog(onDismissRequest = { /* No permitir cerrar */ }) { + Card( + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Box(modifier = Modifier.padding(32.dp)) { + Text( + text = stringResource(R.string.generating_image) + "\n" + stringResource(R.string.please_wait), + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + + if (showShareDialog && shareDialogData != null) { + ShareLyricsDialog( + lyricsText = shareDialogData!!.first, + songTitle = shareDialogData!!.second, + artists = shareDialogData!!.third, + mediaMetadata = mediaMetadata, + onDismiss = { + showShareDialog = false + shareDialogData = null + } + ) + } +} + +@SuppressLint("LocalContextGetResourceValueCall") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareLyricsDialog( + lyricsText: String, + songTitle: String, + artists: String, + mediaMetadata: com.darkxvenom.airbeats.models.MediaMetadata?, + onDismiss: () -> Unit, + onShareAsImage: (String, String, String) -> Unit = { _, _, _ -> } +) { + val context = LocalContext.current + + BasicAlertDialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(0.85f) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = stringResource(R.string.share_lyrics), + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.height(16.dp)) + + // Share as text + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + val songLink = + "https://music.youtube.com/watch?v=${mediaMetadata?.id}" + putExtra( + Intent.EXTRA_TEXT, + "\"$lyricsText\"\n\n$songTitle - $artists\n$songLink" + ) + } + context.startActivity( + Intent.createChooser( + shareIntent, + context.getString(R.string.share_lyrics) + ) + ) + onDismiss() + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.media3_icon_share), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.share_as_text), + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + + // Share as image + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onShareAsImage(lyricsText, songTitle, artists) + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.media3_icon_share), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.share_as_image), + fontSize = 16.sp, + color = MaterialTheme.colorScheme.onSurface + ) + } + + // Cancel button + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.End, + ) { + Text( + text = stringResource(R.string.cancel), + fontSize = 16.sp, + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clickable { onDismiss() } + .padding(vertical = 8.dp, horizontal = 12.dp) + ) + } + } + } + } +} + +// Preview time constant +val LyricsPreviewTime = 2.seconds +const val ANIMATE_SCROLL_DURATION = 300L \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/NamePreferenceManager.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/NamePreferenceManager.kt new file mode 100644 index 00000000..c6882b7e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/NamePreferenceManager.kt @@ -0,0 +1,40 @@ +package com.darkxvenom.airbeats.ui.component + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.nameDataStore by preferencesDataStore("user_name_preferences") + +@Singleton +class NamePreferenceManager @Inject constructor( + @ApplicationContext private val context: Context +) { + companion object { + private val USER_NAME_KEY = stringPreferencesKey("user_name") + private val NAME_SET_KEY = stringPreferencesKey("name_set") + } + + val userName: Flow = context.nameDataStore.data + .map { preferences -> + preferences[USER_NAME_KEY] ?: "" + } + + val isNameSet: Flow = context.nameDataStore.data + .map { preferences -> + preferences[NAME_SET_KEY]?.toBoolean() ?: false + } + + suspend fun saveUserName(name: String) { + context.nameDataStore.edit { preferences -> + preferences[USER_NAME_KEY] = name + preferences[NAME_SET_KEY] = "true" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/NameProvider.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/NameProvider.kt new file mode 100644 index 00000000..b023d62c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/NameProvider.kt @@ -0,0 +1,64 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.runtime.staticCompositionLocalOf +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +val LocalUserName = staticCompositionLocalOf { "" } + +@Composable +fun NameProvider( + namePreferenceManager: NamePreferenceManager, + content: @Composable () -> Unit +) { + var userName by remember { mutableStateOf("") } + var showNameDialog by remember { mutableStateOf(false) } + var isInitialized by remember { mutableStateOf(false) } + + // Check if name is set when component first loads + LaunchedEffect(Unit) { + val isNameSet = namePreferenceManager.isNameSet.first() + if (!isNameSet) { + showNameDialog = true + } else { + userName = namePreferenceManager.userName.first() + } + isInitialized = true + } + + // Handle name confirmation + val onNameConfirmed: (String) -> Unit = { name -> + if (name.isNotBlank()) { + userName = name + showNameDialog = false + // Save to preferences + runBlocking { + namePreferenceManager.saveUserName(name) + } + } + } + + CompositionLocalProvider( + LocalUserName provides userName + ) { + content() + } + + // Show dialog if needed + if (showNameDialog && isInitialized) { + NameSetupDialog( + onNameConfirmed = onNameConfirmed, + onDismiss = { + // If user skips, set a default name + onNameConfirmed("Friend") + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/NameSetupDialog.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/NameSetupDialog.kt new file mode 100644 index 00000000..52724b4e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/NameSetupDialog.kt @@ -0,0 +1,131 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.foundation.layout.Arrangement +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp + +@Composable +fun NameSetupDialog( + onNameConfirmed: (String) -> Unit, + onDismiss: () -> Unit +) { + var nameInput by remember { mutableStateOf(TextFieldValue("")) } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val isNameValid = nameInput.text.length in 1..9 + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = "Welcome! 👋", + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "What should I call you?", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 16.dp) + ) + + OutlinedTextField( + value = nameInput, + onValueChange = { + if (it.text.length <= 9) { + nameInput = it + } + }, + label = { Text("Your name") }, + placeholder = { Text("Max 9 letters") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (isNameValid) { + keyboardController?.hide() + onNameConfirmed(nameInput.text) + } + } + ), + isError = nameInput.text.isNotEmpty() && !isNameValid, + supportingText = { + if (nameInput.text.isNotEmpty() && !isNameValid) { + Text( + text = "Name must be 1-9 characters", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "${nameInput.text.length}/9", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + Button( + onClick = { + keyboardController?.hide() + onNameConfirmed(nameInput.text) + }, + enabled = isNameValid, + shape = RoundedCornerShape(8.dp) + ) { + Text("Continue") + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(8.dp) + ) { + Text("Cancel") + } + }, + shape = RoundedCornerShape(16.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/NavigationTitle.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/NavigationTitle.kt new file mode 100644 index 00000000..d529a90e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/NavigationTitle.kt @@ -0,0 +1,77 @@ +package com.darkxvenom.airbeats.ui.component + +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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.R + +@Composable +fun NavigationTitle( + title: String, + modifier: Modifier = Modifier, + label: String? = null, + thumbnail: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .clickable(enabled = onClick != null) { + onClick?.invoke() + } + .padding(horizontal = 12.dp, vertical = 12.dp) + ) { + thumbnail?.invoke() + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) + ) { + label?.let { label -> + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + + if (onClick != null) { + Icon( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/PlayerSlider.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/PlayerSlider.kt new file mode 100644 index 00000000..fb959cb7 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/PlayerSlider.kt @@ -0,0 +1,112 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.SliderState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.lerp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayerSliderTrack( + sliderState: SliderState, + modifier: Modifier = Modifier, + colors: SliderColors = SliderDefaults.colors(), + trackHeight: Dp = 10.dp +) { + val inactiveTrackColor = colors.inactiveTrackColor + val activeTrackColor = colors.activeTrackColor + val inactiveTickColor = colors.inactiveTickColor + val activeTickColor = colors.activeTickColor + val valueRange = sliderState.valueRange + Canvas( + modifier + .fillMaxWidth() + .height(trackHeight) + ) { + drawTrack( + stepsToTickFractions(sliderState.steps), + 0f, + calcFraction( + valueRange.start, + valueRange.endInclusive, + sliderState.value.coerceIn(valueRange.start, valueRange.endInclusive) + ), + inactiveTrackColor, + activeTrackColor, + inactiveTickColor, + activeTickColor, + trackHeight + ) + } +} + +private fun DrawScope.drawTrack( + tickFractions: FloatArray, + activeRangeStart: Float, + activeRangeEnd: Float, + inactiveTrackColor: Color, + activeTrackColor: Color, + inactiveTickColor: Color, + activeTickColor: Color, + trackHeight: Dp = 2.dp +) { + val isRtl = layoutDirection == LayoutDirection.Rtl + val sliderLeft = Offset(0f, center.y) + val sliderRight = Offset(size.width, center.y) + val sliderStart = if (isRtl) sliderRight else sliderLeft + val sliderEnd = if (isRtl) sliderLeft else sliderRight + val tickSize = 2.0.dp.toPx() + val trackStrokeWidth = trackHeight.toPx() + drawLine( + inactiveTrackColor, + sliderStart, + sliderEnd, + trackStrokeWidth, + StrokeCap.Round + ) + val sliderValueEnd = Offset( + sliderStart.x + + (sliderEnd.x - sliderStart.x) * activeRangeEnd, + center.y + ) + val sliderValueStart = Offset( + sliderStart.x + + (sliderEnd.x - sliderStart.x) * activeRangeStart, + center.y + ) + drawLine( + activeTrackColor, + sliderValueStart, + sliderValueEnd, + trackStrokeWidth, + StrokeCap.Round + ) + for (tick in tickFractions) { + val outsideFraction = tick > activeRangeEnd || tick < activeRangeStart + drawCircle( + color = if (outsideFraction) inactiveTickColor else activeTickColor, + center = Offset(lerp(sliderStart, sliderEnd, tick).x, center.y), + radius = tickSize / 2f + ) + } +} + +private fun stepsToTickFractions(steps: Int): FloatArray { + return if (steps == 0) floatArrayOf() else FloatArray(steps + 2) { it.toFloat() / (steps + 1) } +} + +private fun calcFraction(a: Float, b: Float, pos: Float) = + (if (b - a == 0f) 0f else (pos - a) / (b - a)).coerceIn(0f, 1f) diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/PlayingIndicator.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/PlayingIndicator.kt new file mode 100644 index 00000000..4ed2b8ba --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/PlayingIndicator.kt @@ -0,0 +1,113 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +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.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +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.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.random.Random + +@Composable +fun PlayingIndicator( + color: Color, + modifier: Modifier = Modifier, + bars: Int = 3, + barWidth: Dp = 4.dp, + cornerRadius: Dp = ThumbnailCornerRadius, +) { + val animatables = + remember { + List(bars) { + Animatable(0.1f) + } + } + + LaunchedEffect(Unit) { + delay(300) + animatables.forEach { animatable -> + launch { + while (true) { + animatable.animateTo(Random.nextFloat() * 0.9f + 0.1f) + delay(50) + } + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Bottom, + modifier = modifier, + ) { + animatables.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()), + ) + } + } + } +} + +@Composable +fun PlayingIndicatorBox( + modifier: Modifier = Modifier, + isActive: Boolean, + playWhenReady: Boolean, + color: Color = Color.White, +) { + AnimatedVisibility( + visible = isActive, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier, + ) { + if (playWhenReady) { + PlayingIndicator( + color = color, + modifier = Modifier.height(24.dp), + ) + } else { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + tint = color, + ) + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/Preference.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Preference.kt new file mode 100644 index 00000000..72bec1aa --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/Preference.kt @@ -0,0 +1,358 @@ +package com.darkxvenom.airbeats.ui.component + +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.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.R +import kotlin.math.roundToInt + +@Composable +fun PreferenceEntry( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + description: String? = null, + content: (@Composable () -> Unit)? = null, + icon: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, + isEnabled: Boolean = true, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable( + enabled = isEnabled && onClick != null, + onClick = onClick ?: {}, + ) + .alpha(if (isEnabled) 1f else 0.5f) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon with background + if (icon != null) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ), + contentAlignment = Alignment.Center + ) { + icon() + } + + Spacer(Modifier.width(16.dp)) + } + + // Title, description and content + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f), + ) { + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + title() + } + + if (description != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + content?.invoke() + } + + // Trailing content + if (trailingContent != null) { + Spacer(Modifier.width(8.dp)) + trailingContent() + } + } +} + +@Composable +fun ListPreference( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + icon: (@Composable () -> Unit)? = null, + selectedValue: T, + values: List, + valueText: @Composable (T) -> String, + onValueSelected: (T) -> Unit, + isEnabled: Boolean = true, +) { + var showDialog by remember { + mutableStateOf(false) + } + + if (showDialog) { + ListDialog( + onDismiss = { showDialog = false }, + ) { + items(values) { value -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + showDialog = false + onValueSelected(value) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + RadioButton( + selected = value == selectedValue, + onClick = null, + ) + + Text( + text = valueText(value), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp), + ) + } + } + } + } + + PreferenceEntry( + modifier = modifier, + title = title, + description = valueText(selectedValue), + icon = icon, + onClick = { showDialog = true }, + isEnabled = isEnabled, + ) +} + +@Composable +inline fun > EnumListPreference( + modifier: Modifier = Modifier, + noinline title: @Composable () -> Unit, + noinline icon: (@Composable () -> Unit)?, + selectedValue: T, + noinline valueText: @Composable (T) -> String, + noinline onValueSelected: (T) -> Unit, + isEnabled: Boolean = true, + values: List = enumValues().toList(), +) { + ListPreference( + modifier = modifier, + title = title, + icon = icon, + selectedValue = selectedValue, + values = values, + valueText = valueText, + onValueSelected = onValueSelected, + isEnabled = isEnabled, + ) +} + +@Composable +fun SwitchPreference( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + description: String? = null, + icon: (@Composable () -> Unit)? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + isEnabled: Boolean = true, +) { + PreferenceEntry( + modifier = modifier, + title = title, + description = description, + icon = icon, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = isEnabled, + thumbContent = { + Icon( + painter = painterResource( + id = if (checked) R.drawable.check else R.drawable.close + ), + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + ) + }, + onClick = { onCheckedChange(!checked) }, + isEnabled = isEnabled + ) +} + +@Composable +fun EditTextPreference( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + icon: (@Composable () -> Unit)? = null, + value: String, + onValueChange: (String) -> Unit, + singleLine: Boolean = true, + isInputValid: (String) -> Boolean = { it.isNotEmpty() }, + isEnabled: Boolean = true, +) { + var showDialog by remember { + mutableStateOf(false) + } + + if (showDialog) { + TextFieldDialog( + initialTextFieldValue = + TextFieldValue( + text = value, + selection = TextRange(value.length), + ), + singleLine = singleLine, + isInputValid = isInputValid, + onDone = onValueChange, + onDismiss = { showDialog = false }, + ) + } + + PreferenceEntry( + modifier = modifier, + title = title, + description = value, + icon = icon, + onClick = { showDialog = true }, + isEnabled = isEnabled, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SliderPreference( + modifier: Modifier = Modifier, + title: @Composable () -> Unit, + icon: (@Composable () -> Unit)? = null, + value: Float, + onValueChange: (Float) -> Unit, + isEnabled: Boolean = true, +) { + var showDialog by remember { + mutableStateOf(false) + } + + var sliderValue by remember { + mutableFloatStateOf(value) + } + + if (showDialog) { + ActionPromptDialog( + titleBar = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.history_duration), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.headlineSmall, + ) + } + }, + onDismiss = { showDialog = false }, + onConfirm = { + showDialog = false + onValueChange.invoke(sliderValue) + }, + onCancel = { + sliderValue = value + showDialog = false + }, + onReset = { + sliderValue = 30f + }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = pluralStringResource( + R.plurals.seconds, + sliderValue.roundToInt(), + sliderValue.roundToInt() + ), + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(Modifier.height(16.dp)) + + Slider( + value = sliderValue, + onValueChange = { sliderValue = it }, + valueRange = 15f..60f, + modifier = Modifier.fillMaxWidth() + ) + } + } + ) + } + + PreferenceEntry( + modifier = modifier, + title = title, + description = value.roundToInt().toString(), + icon = icon, + onClick = { showDialog = true }, + isEnabled = isEnabled, + ) +} + +@Composable +fun PreferenceGroupTitle( + title: String, + modifier: Modifier = Modifier, +) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp), + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/ReleaseNotesCard.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ReleaseNotesCard.kt new file mode 100644 index 00000000..d07f69ca --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ReleaseNotesCard.kt @@ -0,0 +1,83 @@ +package com.darkxvenom.airbeats.ui.component + +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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jsoup.Jsoup + +@Composable +fun ReleaseNotesCard() { + var releaseNotes by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + releaseNotes = fetchReleaseNotesText() + } + + Card( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(R.string.release_notes), + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + releaseNotes.forEach { note -> + Text( + text = "• $note", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 2.dp) + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) +} + +suspend fun fetchReleaseNotesText(): List { + return withContext(Dispatchers.IO) { + try { + val document = + Jsoup.connect("https://github.com/darkxvenom/airbeats/releases/latest").get() + val changelogElement = document.selectFirst(".markdown-body") + val htmlContent = changelogElement?.html() ?: "No release notes found" + + val textContent = htmlContent + .replace(Regex("|

"), "\n") + .replace(Regex("<.*?>"), "") + + textContent.split("\n") + .map { it.trim() } + .filter { it.isNotEmpty() } + } catch (e: Exception) { + listOf("Error loading release notes") + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/SearchBar.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SearchBar.kt new file mode 100644 index 00000000..33b497e6 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SearchBar.kt @@ -0,0 +1,334 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.darkxvenom.airbeats.ui.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarColors +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.util.lerp +import com.darkxvenom.airbeats.constants.AppBarHeight +import kotlin.math.max + +@ExperimentalMaterial3Api +@Composable +fun TopSearch( + query: TextFieldValue, + onQueryChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + shape: Shape = SearchBarDefaults.inputFieldShape, + colors: SearchBarColors = SearchBarDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + tonalElevation: Dp = SearchBarDefaults.TonalElevation, + windowInsets: WindowInsets = WindowInsets.safeDrawing, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + focusRequester: FocusRequester = remember { FocusRequester() }, + content: @Composable ColumnScope.() -> Unit, +) { + val animationProgress: Float by animateFloatAsState( + targetValue = if (active) 1f else 0f, + animationSpec = tween(durationMillis = AnimationDurationMillis), + label = "SearchBarAnimation", + ) + + val defaultInputFieldShape = SearchBarDefaults.inputFieldShape + val defaultFullScreenShape = SearchBarDefaults.fullScreenShape + val animatedShape by remember { + derivedStateOf { + when { + shape == defaultInputFieldShape -> { + val animatedRadius = SearchBarCornerRadius * (1 - animationProgress) + RoundedCornerShape(CornerSize(animatedRadius)) + } + + animationProgress == 1f -> defaultFullScreenShape + else -> shape + } + } + } + + val topInset = windowInsets.asPaddingValues().calculateTopPadding() + val startInset = + windowInsets.asPaddingValues().calculateStartPadding(LocalLayoutDirection.current) + val endInset = windowInsets.asPaddingValues().calculateEndPadding(LocalLayoutDirection.current) + + val topPadding = SearchBarVerticalPadding + topInset + val animatedSurfaceTopPadding = lerp(topPadding, 0.dp, animationProgress) + val animatedInputFieldPadding by remember { + derivedStateOf { + PaddingValues( + start = startInset * animationProgress, + top = topPadding * animationProgress, + end = endInset * animationProgress, + bottom = SearchBarVerticalPadding * animationProgress, + ) + } + } + + BoxWithConstraints( + modifier = modifier.offset { IntOffset(x = 0, y = 0) }, + propagateMinConstraints = true, + ) { + val height: Dp + val width: Dp + val startPadding: Dp + val endPadding: Dp + with(LocalDensity.current) { + val startWidth = constraints.maxWidth.toFloat() + val startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx()) + .coerceAtMost(constraints.maxHeight) + .toFloat() + val endWidth = constraints.maxWidth.toFloat() + val endHeight = constraints.maxHeight.toFloat() + + height = lerp(startHeight, endHeight, animationProgress).toDp() + width = lerp(startWidth, endWidth, animationProgress).toDp() + startPadding = lerp( + (SearchBarHorizontalPadding + startInset).roundToPx().toFloat(), + 0f, + animationProgress + ).toDp() + endPadding = lerp( + (SearchBarHorizontalPadding + endInset).roundToPx().toFloat(), + 0f, + animationProgress + ).toDp() + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(topInset + AppBarHeight) + .background(color = MaterialTheme.colorScheme.surface) + ) + + Surface( + shape = animatedShape, + color = colors.containerColor, + contentColor = contentColorFor(colors.containerColor), + tonalElevation = tonalElevation, + modifier = Modifier + .padding( + top = animatedSurfaceTopPadding, + start = startPadding, + end = endPadding, + ) + .size(width = width, height = height), + ) { + Column { + SearchBarInputField( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + modifier = Modifier.padding(animatedInputFieldPadding), + enabled = enabled, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + colors = TextFieldDefaults.colors( + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + interactionSource = interactionSource, + focusRequester = focusRequester, + ) + + if (animationProgress > 0) { + Column(Modifier.alpha(animationProgress)) { + HorizontalDivider(color = colors.dividerColor) + content() + } + } + } + } + } + + BackHandler(enabled = active) { + onActiveChange(false) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchBarInputField( + query: TextFieldValue, + onQueryChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + colors: TextFieldColors, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + val focused = interactionSource.collectIsFocusedAsState().value + val textColor = LocalTextStyle.current.color.takeOrElse { + if (focused) colors.focusedTextColor else colors.unfocusedTextColor + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .height(InputFieldHeight), + ) { + if (leadingIcon != null) { + Spacer(Modifier.width(SearchBarIconOffsetX)) + leadingIcon() + } + + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) { + onActiveChange(true) + } + } + } + .semantics { + contentDescription = "Search" + if (active) { + stateDescription = "Suggestions available" + } + } + .onKeyEvent { + if (it.key == Key.Enter) { + onSearch(query.text) + return@onKeyEvent true + } + false + }, + enabled = enabled, + singleLine = true, + textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)), + cursorBrush = SolidColor(colors.cursorColor), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch(query.text) }), + interactionSource = interactionSource, + decorationBox = @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = query.text, + innerTextField = innerTextField, + enabled = enabled, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + placeholder = placeholder, + shape = SearchBarDefaults.inputFieldShape, + colors = colors, + contentPadding = PaddingValues(), + container = {}, + ) + }, + ) + + if (trailingIcon != null) { + trailingIcon() + Spacer(Modifier.width(SearchBarIconOffsetX)) + } + } +} + +// Measurement specs +val InputFieldHeight = 48.dp +private val SearchBarCornerRadius: Dp = InputFieldHeight / 2 +internal val SearchBarVerticalPadding: Dp = 8.dp +internal val SearchBarHorizontalPadding: Dp = 12.dp +val SearchBarIconOffsetX: Dp = 4.dp +private const val AnimationDurationMillis: Int = 300 \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/SettingsCategory.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SettingsCategory.kt new file mode 100644 index 00000000..fa08e4a5 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SettingsCategory.kt @@ -0,0 +1,236 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +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.painter.Painter +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsCategory( + title: String? = null, + items: List +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + // Section title + title?.let { + Text( + text = it, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 0.dp, bottom = 8.dp, top = 8.dp) + ) + } + + // Settings card + Card( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column { + items.forEachIndexed { index, item -> + Material3SettingsItemRow( + item = item, + showDivider = index < items.size - 1 + ) + } + } + } + } +} + +@Composable +fun SettingsGeneralCategory( + title: String? = null, + items: List<@Composable () -> Unit> +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + // Section title + title?.let { + Text( + text = it, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 0.dp, bottom = 8.dp, top = 8.dp) + ) + } + + // Settings card + Card( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column { + items.forEachIndexed { index, item -> + item() + if (index < items.size - 1) { + HorizontalDivider( + modifier = Modifier.padding( + start = 76.dp, + end = 20.dp + ), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) + } + } + } + } + } +} + +/** Individual settings item row with Material 3 styling */ +@Composable +private fun Material3SettingsItemRow( + item: SettingsCategoryItem, + showDivider: Boolean +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable( + enabled = item.onClick != null, + onClick = { item.onClick?.invoke() } + ) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon with background + item.icon?.let { icon -> + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + MaterialTheme.colorScheme.primary.copy( + alpha = if (item.isHighlighted) 0.15f else 0.1f + ) + ), + contentAlignment = Alignment.Center + ) { + if (item.showBadge) { + BadgedBox( + badge = { + Badge( + containerColor = MaterialTheme.colorScheme.error + ) + } + ) { + Icon( + painter = icon, + contentDescription = null, + tint = if (item.isHighlighted) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), + modifier = Modifier.size(24.dp) + ) + } + } else { + Icon( + painter = icon, + contentDescription = null, + tint = if (item.isHighlighted) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + } + + // Title and description + Column( + modifier = Modifier.weight(1f) + ) { + // Title content + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + item.title() + } + + // Description if provided + item.description?.let { desc -> + Spacer(modifier = Modifier.height(2.dp)) + desc() + } + } + + // Trailing content + item.trailingContent?.let { trailing -> + Spacer(modifier = Modifier.width(8.dp)) + trailing() + } + } + + // Divider + if (showDivider) { + HorizontalDivider( + modifier = Modifier.padding( + start = if (item.icon != null) 76.dp else 20.dp, + end = 20.dp + ), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) + } + } +} + +/** Data class for Material 3 settings item */ +data class SettingsCategoryItem( + val icon: Painter? = null, + val title: @Composable () -> Unit, + val description: (@Composable () -> Unit)? = null, + val trailingContent: (@Composable () -> Unit)? = null, + val showBadge: Boolean = false, + val isHighlighted: Boolean = false, + val onClick: (() -> Unit)? = null +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/SettingsPage.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SettingsPage.kt new file mode 100644 index 00000000..4141c84d --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SettingsPage.kt @@ -0,0 +1,83 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.ui.utils.backToMain + +@ExperimentalMaterial3Api +@Composable +fun SettingsPage(title: String, navController: NavController, scrollBehavior: TopAppBarScrollBehavior, modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.Start, verticalArrangement: Arrangement.Vertical = Arrangement.Top, items: @Composable () -> Unit) { + Column( + modifier = modifier + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + .verticalScroll(rememberScrollState()), + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment + ){ + Spacer( + Modifier.windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top) + ) + ) + + items() + + Spacer( + Modifier.height(16.dp) + ) + } + SettingsTopAppBar( + title, + navController, + scrollBehavior + ) +} + +@ExperimentalMaterial3Api +@Composable +fun SettingsTopAppBar(title: String, navController: NavController, scrollBehavior: TopAppBarScrollBehavior) { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = stringResource(R.string.back), + ) + } + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 20.dp, bottomEnd = 20.dp)), + scrollBehavior = scrollBehavior + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/ShapeSelector.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ShapeSelector.kt new file mode 100644 index 00000000..443ae9c8 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ShapeSelector.kt @@ -0,0 +1,358 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Album +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.ShapeLine +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.toShape +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.RoundedPolygon +import com.darkxvenom.airbeats.R + +data class SmallButtonShapeOption( + val name: String, + val shape: RoundedPolygon, + val displayName: String +) + +enum class ShapeType { + SMALL_BUTTONS, + PLAY_PAUSE, + MINIPLAYER_THUMBNAIL +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun UnifiedShapeBottomSheet( + selectedSmallButtonsShape: String, + selectedPlayPauseShape: String, + selectedMiniPlayerShape: String, + onSmallButtonsShapeSelected: (String) -> Unit, + onPlayPauseShapeSelected: (String) -> Unit, + onMiniPlayerShapeSelected: (String) -> Unit, + onDismiss: () -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(), + initialTab: ShapeType = ShapeType.SMALL_BUTTONS +) { + var selectedTabIndex by remember { mutableIntStateOf( + when (initialTab) { + ShapeType.SMALL_BUTTONS -> 0 + ShapeType.PLAY_PAUSE -> 1 + ShapeType.MINIPLAYER_THUMBNAIL -> 2 + } + ) } + + val availableShapes = remember { + listOf( + SmallButtonShapeOption("Pill", MaterialShapes.Pill, "Pill"), + SmallButtonShapeOption("Circle", MaterialShapes.Circle, "Circle"), + SmallButtonShapeOption("Square", MaterialShapes.Square, "Square"), + SmallButtonShapeOption("Diamond", MaterialShapes.Diamond, "Diamond"), + SmallButtonShapeOption("Pentagon", MaterialShapes.Pentagon, "Pentagon"), + SmallButtonShapeOption("Heart", MaterialShapes.Heart, "Heart"), + SmallButtonShapeOption("Oval", MaterialShapes.Oval, "Oval"), + SmallButtonShapeOption("Arch", MaterialShapes.Arch, "Arch"), + SmallButtonShapeOption("SemiCircle", MaterialShapes.SemiCircle, "Semicircle"), + SmallButtonShapeOption("Triangle", MaterialShapes.Triangle, "Triangle"), + SmallButtonShapeOption("Arrow", MaterialShapes.Arrow, "Arrow"), + SmallButtonShapeOption("Fan", MaterialShapes.Fan, "Fan"), + SmallButtonShapeOption("Gem", MaterialShapes.Gem, "Gem"), + SmallButtonShapeOption("Bun", MaterialShapes.Bun, "Bun"), + SmallButtonShapeOption("Ghostish", MaterialShapes.Ghostish, "Ghost-ish"), + SmallButtonShapeOption("Cookie4Sided", MaterialShapes.Cookie4Sided, "Cookie 4"), + SmallButtonShapeOption("Cookie6Sided", MaterialShapes.Cookie6Sided, "Cookie 6"), + SmallButtonShapeOption("Cookie7Sided", MaterialShapes.Cookie7Sided, "Cookie 7"), + SmallButtonShapeOption("Cookie9Sided", MaterialShapes.Cookie9Sided, "Cookie 9"), + SmallButtonShapeOption("Cookie12Sided", MaterialShapes.Cookie12Sided, "Cookie 12"), + SmallButtonShapeOption("Clover4Leaf", MaterialShapes.Clover4Leaf, "Clover 4"), + SmallButtonShapeOption("Clover8Leaf", MaterialShapes.Clover8Leaf, "Clover 8"), + SmallButtonShapeOption("Sunny", MaterialShapes.Sunny, "Sunny"), + SmallButtonShapeOption("VerySunny", MaterialShapes.VerySunny, "Very Sunny"), + SmallButtonShapeOption("Burst", MaterialShapes.Burst, "Burst"), + SmallButtonShapeOption("SoftBurst", MaterialShapes.SoftBurst, "Soft Burst"), + SmallButtonShapeOption("Boom", MaterialShapes.Boom, "Boom"), + SmallButtonShapeOption("SoftBoom", MaterialShapes.SoftBoom, "Soft Boom"), + SmallButtonShapeOption("Flower", MaterialShapes.Flower, "Flower"), + SmallButtonShapeOption("PixelCircle", MaterialShapes.PixelCircle, "Pixel Circle"), + SmallButtonShapeOption("PixelTriangle", MaterialShapes.PixelTriangle, "Pixel Triangle"), + SmallButtonShapeOption("Puffy", MaterialShapes.Puffy, "Puffy"), + SmallButtonShapeOption("PuffyDiamond", MaterialShapes.PuffyDiamond, "Puffy Diamond"), + SmallButtonShapeOption("Slanted", MaterialShapes.Slanted, "Slanted"), + SmallButtonShapeOption("ClamShell", MaterialShapes.ClamShell, "Clam Shell") + ) + } + + val tabTitles = listOf("Small Buttons", "Play/Pause", "MiniPlayer") + val tabIcons = listOf(Icons.Default.ShapeLine, Icons.Default.PlayArrow, Icons.Default.Album) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp) + ) { + Text( + text = "Shape Selector", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Text( + text = "Customize shapes for all buttons", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 20.dp) + ) + + TabRow( + selectedTabIndex = selectedTabIndex, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(bottom = 20.dp) + .clip(RoundedCornerShape(12.dp)) + ) { + tabTitles.forEachIndexed { index, title -> + Tab( + selected = selectedTabIndex == index, + onClick = { selectedTabIndex = index }, + icon = { + Icon( + imageVector = tabIcons[index], + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + }, + text = { + Text( + text = title, + style = MaterialTheme.typography.labelMedium + ) + }, + selectedContentColor = MaterialTheme.colorScheme.primary, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + val currentSelectedShape = when (selectedTabIndex) { + 0 -> selectedSmallButtonsShape + 1 -> selectedPlayPauseShape + 2 -> selectedMiniPlayerShape + else -> selectedSmallButtonsShape + } + + LazyVerticalGrid( + columns = GridCells.Fixed(4), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.heightIn(max = 400.dp) + ) { + items(availableShapes) { shapeOption -> + SmallButtonShapeItem( + shapeOption = shapeOption, + isSelected = shapeOption.name == currentSelectedShape, + onClick = { + when (selectedTabIndex) { + 0 -> onSmallButtonsShapeSelected(shapeOption.name) + 1 -> onPlayPauseShapeSelected(shapeOption.name) + 2 -> onMiniPlayerShapeSelected(shapeOption.name) + } + onDismiss() + } + ) + } + } + } + } +} + +/** + * Item individual de forma con animaciones sutiles y feedback visual + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SmallButtonShapeItem( + shapeOption: SmallButtonShapeOption, + isSelected: Boolean, + onClick: () -> Unit +) { + // Animación de escala suave + val scale by animateFloatAsState( + targetValue = if (isSelected) 1.05f else 1f, + animationSpec = spring( + dampingRatio = 0.7f, + stiffness = 300f + ), + label = "scale" + ) + + // Transición de color del contenedor + val backgroundColor by animateColorAsState( + targetValue = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceContainerHighest, + animationSpec = tween(250), + label = "backgroundColor" + ) + + // Color del borde con transición + val borderColor by animateColorAsState( + targetValue = if (isSelected) + MaterialTheme.colorScheme.primary + else + Color.Transparent, + animationSpec = tween(250), + label = "borderColor" + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .scale(scale) + .aspectRatio(1f) + .clip(RoundedCornerShape(16.dp)) + .background(backgroundColor) + .border( + width = if (isSelected) 2.dp else 0.dp, + color = borderColor, + shape = RoundedCornerShape(16.dp) + ) + .clickable(onClick = onClick) + .padding(8.dp) + ) { + // Preview de la forma + Box( + modifier = Modifier + .size(40.dp) + .clip(shapeOption.shape.toShape()) + .background( + if (isSelected) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + + // Nombre de la forma + Text( + text = shapeOption.displayName, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + minLines = 2, + color = if (isSelected) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurface + ) + } +} + +/** + * Botón selector unificado para todas las formas (UN SOLO BOTÓN) + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun UnifiedShapeSelectorButton( + smallButtonsShape: String, + playPauseShape: String, + miniPlayerShape: String, + onSmallButtonsShapeSelected: (String) -> Unit, + onPlayPauseShapeSelected: (String) -> Unit, + onMiniPlayerShapeSelected: (String) -> Unit, + modifier: Modifier = Modifier +) { + var showBottomSheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + + // UN SOLO BOTÓN que muestra un resumen + PreferenceEntry( + title = { Text("Shape Selector") }, + description = "Customize button shapes", + icon = { + Icon( + painter = painterResource(R.drawable.scatter_plot), + contentDescription = null + ) + }, + onClick = { + showBottomSheet = true + }, + modifier = modifier + ) + + if (showBottomSheet) { + UnifiedShapeBottomSheet( + selectedSmallButtonsShape = smallButtonsShape, + selectedPlayPauseShape = playPauseShape, + selectedMiniPlayerShape = miniPlayerShape, + onSmallButtonsShapeSelected = onSmallButtonsShapeSelected, + onPlayPauseShapeSelected = onPlayPauseShapeSelected, + onMiniPlayerShapeSelected = onMiniPlayerShapeSelected, + onDismiss = { showBottomSheet = false }, + sheetState = sheetState, + initialTab = ShapeType.SMALL_BUTTONS + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/ShareLyrics.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ShareLyrics.kt new file mode 100644 index 00000000..5a2813d5 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ShareLyrics.kt @@ -0,0 +1,801 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.models.MediaMetadata +import kotlinx.coroutines.launch +import kotlin.math.min +import androidx.compose.ui.res.stringResource + +// Enums +enum class FontStyle { + REGULAR, BOLD, EXTRA_BOLD +} + +enum class LogoPosition { + BOTTOM_LEFT, BOTTOM_RIGHT, TOP_LEFT, TOP_RIGHT, NONE +} + +enum class BackgroundStyle { + SOLID, GRADIENT, PATTERN +} + +enum class TextAlignment { + LEFT, CENTER, RIGHT +} + +enum class LogoSize { + SMALL, MEDIUM, LARGE +} + +enum class CoverArtStyle { + ROUNDED, CIRCLE, SQUARE +} + +enum class LyricsStyle { + NORMAL, ITALIC, CONDENSED +} + +// Data classes +data class ImageCustomization( + val backgroundColor: Color = Color(0xFF1A1A1A), + val textColor: Color = Color.White, + val secondaryTextColor: Color = Color.White.copy(alpha = 0.7f), + val backgroundStyle: BackgroundStyle = BackgroundStyle.SOLID, + val gradientColors: List? = null, + val fontStyle: FontStyle = FontStyle.EXTRA_BOLD, + val showCoverArt: Boolean = true, + val showSongTitle: Boolean = true, + val showArtistName: Boolean = true, + val showLogo: Boolean = true, + val logoPosition: LogoPosition = LogoPosition.BOTTOM_RIGHT, + val logoSize: LogoSize = LogoSize.MEDIUM, + val patternOpacity: Float = 0.05f, + val cornerRadius: Float = 16f, + val isDark: Boolean = true, + val textAlignment: TextAlignment = TextAlignment.CENTER, + val padding: Float = 24f, + val textShadowEnabled: Boolean = true, + val borderEnabled: Boolean = false, + val borderColor: Color = Color.White.copy(alpha = 0.3f), + val borderWidth: Float = 2f, + val coverArtStyle: CoverArtStyle = CoverArtStyle.ROUNDED, + val lyricsStyle: LyricsStyle = LyricsStyle.NORMAL, + val accentColor: Color? = null, + val showAccentLine: Boolean = false, + val spacingBetweenElements: Float = 16f, + val lyricsLineSpacing: Float = 1.3f +) + +data class ColorPreset( + val name: String, + val customization: ImageCustomization +) + +// Presets unificados +val colorPresets = listOf( + ColorPreset( + "Dark", + ImageCustomization( + backgroundColor = Color(0xFF1A1A1A), + textColor = Color.White, + secondaryTextColor = Color.White.copy(alpha = 0.7f), + isDark = true + ) + ), + ColorPreset( + "Light", + ImageCustomization( + backgroundColor = Color(0xFFF5F5F5), + textColor = Color.Black, + secondaryTextColor = Color.Black.copy(alpha = 0.7f), + isDark = false + ) + ), + ColorPreset( + "Blue", + ImageCustomization( + backgroundColor = Color(0xFF1E3A8A), + textColor = Color.White, + secondaryTextColor = Color.White.copy(alpha = 0.8f), + isDark = true + ) + ), + ColorPreset( + "Purple", + ImageCustomization( + backgroundColor = Color(0xFF4C1D95), + textColor = Color.White, + secondaryTextColor = Color.White.copy(alpha = 0.8f), + isDark = true + ) + ), + ColorPreset( + "Red", + ImageCustomization( + backgroundColor = Color(0xFF991B1B), + textColor = Color.White, + secondaryTextColor = Color.White.copy(alpha = 0.8f), + isDark = true + ) + ), + ColorPreset( + "Green", + ImageCustomization( + backgroundColor = Color(0xFF065F46), + textColor = Color.White, + secondaryTextColor = Color.White.copy(alpha = 0.8f), + isDark = true + ) + ), + ColorPreset( + "Gradient Blue", + ImageCustomization( + backgroundStyle = BackgroundStyle.GRADIENT, + gradientColors = listOf( + Color(0xFF1E3A8A), + Color(0xFF3B82F6), + Color(0xFF60A5FA) + ), + textColor = Color.White, + secondaryTextColor = Color.White.copy(alpha = 0.9f), + isDark = true + ) + ), + ColorPreset( + "Gradient Purple", + ImageCustomization( + backgroundStyle = BackgroundStyle.GRADIENT, + gradientColors = listOf( + Color(0xFF4C1D95), + Color(0xFF7C3AED), + Color(0xFFA78BFA) + ), + textColor = Color.White, + secondaryTextColor = Color.White.copy(alpha = 0.9f), + isDark = true + ) + ), + ColorPreset( + "Gradient Sunset", + ImageCustomization( + backgroundStyle = BackgroundStyle.GRADIENT, + gradientColors = listOf( + Color(0xFFF59E0B), + Color(0xFFEF4444), + Color(0xFF8B5CF6) + ), + textColor = Color.White, + secondaryTextColor = Color.White.copy(alpha = 0.9f), + isDark = true + ) + ) +) + + +@Composable +fun rememberAdjustedFontSize( + text: String, + maxWidth: Dp, + maxHeight: Dp, + density: Density, + initialFontSize: TextUnit = 20.sp, + minFontSize: TextUnit = 14.sp, + style: TextStyle = TextStyle.Default, + textMeasurer: androidx.compose.ui.text.TextMeasurer? = null +): TextUnit { + val measurer = textMeasurer ?: rememberTextMeasurer() + + var calculatedFontSize by remember(text, maxWidth, maxHeight, style, density) { + val initialSize = when { + text.length < 30 -> (initialFontSize.value * 1.1f).sp + text.length < 60 -> initialFontSize + text.length < 120 -> (initialFontSize.value * 0.85f).sp + text.length < 200 -> (initialFontSize.value * 0.7f).sp + else -> (initialFontSize.value * 0.6f).sp + } + mutableStateOf(initialSize) + } + + LaunchedEffect(key1 = text, key2 = maxWidth, key3 = maxHeight) { + val targetWidthPx = with(density) { maxWidth.toPx() * 0.85f } + val targetHeightPx = with(density) { maxHeight.toPx() * 0.8f } + + if (text.isBlank()) { + calculatedFontSize = minFontSize + return@LaunchedEffect + } + + var minSize = minFontSize.value + var maxSize = (initialFontSize.value * 1.2f) + var bestFit = minSize + var iterations = 0 + + while (minSize <= maxSize && iterations < 20) { + iterations++ + val midSize = (minSize + maxSize) / 2 + val midSizeSp = midSize.sp + + val result = measurer.measure( + text = AnnotatedString(text), + style = style.copy( + fontSize = midSizeSp, + fontWeight = FontWeight.ExtraBold, + lineHeight = (midSize * 1.3f).sp, + letterSpacing = 0.3.sp + ) + ) + + if (result.size.width <= targetWidthPx && result.size.height <= targetHeightPx) { + bestFit = midSize + minSize = midSize + 0.5f + } else { + maxSize = midSize - 0.5f + } + } + + calculatedFontSize = if (bestFit < minFontSize.value) minFontSize else bestFit.sp + } + + return calculatedFontSize +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LyricsImageCard( + lyricText: String, + mediaMetadata: MediaMetadata, + selectedCustomization: ImageCustomization = ImageCustomization(), + onCustomizationChange: (ImageCustomization) -> Unit = {}, + onSaveImage: () -> Unit = {}, + showControls: Boolean = true, + modifier: Modifier = Modifier +) { + var isGenerating by remember { mutableStateOf(false) } + val context = LocalContext.current + + Column( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (showControls) { + ModernControlsSection( + selectedCustomization = selectedCustomization, + isGenerating = isGenerating, + onSaveImage = { + isGenerating = true + onSaveImage() + kotlinx.coroutines.GlobalScope.launch { + kotlinx.coroutines.delay(1500) + isGenerating = false + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + LyricsImageCardPreview( + lyricText = lyricText, + mediaMetadata = mediaMetadata, + customization = selectedCustomization, + modifier = Modifier + .padding(horizontal = 20.dp) + .fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Image will be saved in high resolution", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ModernControlsSection( + selectedCustomization: ImageCustomization, + isGenerating: Boolean, + onSaveImage: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + shape = RoundedCornerShape(20.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Share Lyrics", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + FloatingActionButton( + onClick = onSaveImage, + modifier = Modifier.size(48.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + if (isGenerating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon( + painter = painterResource(id = R.drawable.image), + contentDescription = "Save", + modifier = Modifier.size(20.dp) + ) + } + } + } + } +} + +@Composable +fun LyricsImageCardPreview( + lyricText: String, + mediaMetadata: MediaMetadata, + customization: ImageCustomization = ImageCustomization(), + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val density = LocalDensity.current + val configuration = LocalConfiguration.current + + val screenWidth = configuration.screenWidthDp.dp + val cardSize = remember(screenWidth) { + min(screenWidth.value - 40f, 400f).dp + } + + val outerPadding = cardSize * (customization.padding / 380f) + val thumbnailSize = cardSize * 0.21f + + val painter = rememberAsyncImagePainter( + ImageRequest.Builder(context) + .data(mediaMetadata.thumbnailUrl) + .crossfade(true) + .placeholder(R.drawable.music_note) + .error(R.drawable.music_note) + .build() + ) + + val logoSizeMultiplier = when (customization.logoSize) { + LogoSize.SMALL -> 0.7f + LogoSize.MEDIUM -> 1f + LogoSize.LARGE -> 1.3f + } + + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier + .size(cardSize) + .shadow( + elevation = 24.dp, + shape = RoundedCornerShape(customization.cornerRadius.dp), + ambientColor = customization.textColor.copy(alpha = 0.3f), + spotColor = customization.textColor.copy(alpha = 0.5f) + ) + .then( + if (customization.borderEnabled) { + Modifier.border( + width = customization.borderWidth.dp, + color = customization.borderColor, + shape = RoundedCornerShape(customization.cornerRadius.dp) + ) + } else Modifier + ), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + shape = RoundedCornerShape(customization.cornerRadius.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = when (customization.backgroundStyle) { + BackgroundStyle.SOLID -> Brush.linearGradient( + listOf( + customization.backgroundColor, + customization.backgroundColor + ) + ) + BackgroundStyle.GRADIENT -> { + val colors = customization.gradientColors ?: listOf( + customization.backgroundColor, + customization.backgroundColor + ) + Brush.linearGradient( + colors = colors, + start = Offset(0f, 0f), + end = Offset( + Float.POSITIVE_INFINITY, + Float.POSITIVE_INFINITY + ) + ) + } + BackgroundStyle.PATTERN -> Brush.linearGradient( + listOf( + customization.backgroundColor, + customization.backgroundColor + ) + ) + } + ) + ) { + if (customization.backgroundStyle == BackgroundStyle.PATTERN) { + Canvas(modifier = Modifier.fillMaxSize()) { + val pattern = 40.dp.toPx() + for (x in 0..size.width.toInt() step pattern.toInt()) { + for (y in 0..size.height.toInt() step pattern.toInt()) { + drawCircle( + color = customization.textColor.copy(alpha = customization.patternOpacity), + radius = 2.dp.toPx(), + center = Offset(x.toFloat(), y.toFloat()) + ) + } + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(outerPadding), + verticalArrangement = Arrangement.SpaceBetween + ) { + if (customization.showCoverArt) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(customization.spacingBetweenElements.dp) + ) { + val coverShape = when (customization.coverArtStyle) { + CoverArtStyle.CIRCLE -> CircleShape + CoverArtStyle.SQUARE -> RoundedCornerShape(4.dp) + CoverArtStyle.ROUNDED -> RoundedCornerShape(20.dp) + } + + Box( + modifier = Modifier + .size(thumbnailSize) + .clip(coverShape) + .background(customization.textColor.copy(alpha = 0.1f)) + .border( + 1.dp, + customization.textColor.copy(alpha = 0.2f), + coverShape + ) + ) { + Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(coverShape) + ) + } + + Column(modifier = Modifier.weight(1f)) { + if (customization.showSongTitle) { + Text( + text = mediaMetadata.title, + style = MaterialTheme.typography.headlineSmall.copy( + fontSize = 20.sp, + letterSpacing = (-0.5).sp + ), + fontWeight = when (customization.fontStyle) { + FontStyle.REGULAR -> FontWeight.Bold + FontStyle.BOLD -> FontWeight.ExtraBold + FontStyle.EXTRA_BOLD -> FontWeight.Black + }, + color = customization.textColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + lineHeight = 24.sp + ) + } + if (customization.showArtistName) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = mediaMetadata.artists.joinToString { it.name }, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = 16.sp, + letterSpacing = 0.2.sp + ), + color = customization.secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Medium + ) + } + } + } + + if (customization.showAccentLine && customization.accentColor != null) { + Spacer(modifier = Modifier.height(customization.spacingBetweenElements.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .background(customization.accentColor, RoundedCornerShape(2.dp)) + ) + } + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(vertical = customization.spacingBetweenElements.dp), + contentAlignment = when (customization.textAlignment) { + TextAlignment.LEFT -> Alignment.CenterStart + TextAlignment.CENTER -> Alignment.Center + TextAlignment.RIGHT -> Alignment.CenterEnd + } + ) { + val textMeasurer = rememberTextMeasurer() + val optimalFontSize = rememberAdjustedFontSize( + lyricText, maxWidth, maxHeight, density, + textMeasurer = textMeasurer + ) + + val lyricsWeight = when (customization.lyricsStyle) { + LyricsStyle.NORMAL -> when (customization.fontStyle) { + FontStyle.REGULAR -> FontWeight.Bold + FontStyle.BOLD -> FontWeight.ExtraBold + FontStyle.EXTRA_BOLD -> FontWeight.Black + } + LyricsStyle.ITALIC -> FontWeight.Bold + LyricsStyle.CONDENSED -> when (customization.fontStyle) { + FontStyle.REGULAR -> FontWeight.SemiBold + FontStyle.BOLD -> FontWeight.Bold + FontStyle.EXTRA_BOLD -> FontWeight.ExtraBold + } + } + + Text( + text = lyricText, + textAlign = when (customization.textAlignment) { + TextAlignment.LEFT -> TextAlign.Left + TextAlignment.CENTER -> TextAlign.Center + TextAlignment.RIGHT -> TextAlign.Right + }, + fontSize = optimalFontSize, + fontWeight = lyricsWeight, + color = customization.textColor, + lineHeight = optimalFontSize * customization.lyricsLineSpacing, + modifier = Modifier.fillMaxWidth(), + letterSpacing = if (customization.lyricsStyle == LyricsStyle.CONDENSED) (-0.5).sp else 0.3.sp, + style = if (customization.textShadowEnabled) { + TextStyle( + shadow = Shadow( + color = customization.backgroundColor.copy(alpha = 0.5f), + offset = Offset(2f, 2f), + blurRadius = 4f + ), + fontStyle = if (customization.lyricsStyle == LyricsStyle.ITALIC) + androidx.compose.ui.text.font.FontStyle.Italic + else + androidx.compose.ui.text.font.FontStyle.Normal + ) + } else TextStyle( + fontStyle = if (customization.lyricsStyle == LyricsStyle.ITALIC) + androidx.compose.ui.text.font.FontStyle.Italic + else + androidx.compose.ui.text.font.FontStyle.Normal + ) + ) + } + + if (customization.showLogo && customization.logoPosition != LogoPosition.NONE) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = when (customization.logoPosition) { + LogoPosition.BOTTOM_LEFT, LogoPosition.TOP_LEFT -> Alignment.CenterStart + LogoPosition.BOTTOM_RIGHT, LogoPosition.TOP_RIGHT -> Alignment.CenterEnd + LogoPosition.NONE -> Alignment.CenterStart + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + painter = painterResource(id = R.drawable.airbeats), + contentDescription = null, + modifier = Modifier.size((28 * logoSizeMultiplier).dp) + ) + + Text( + text = stringResource(R.string.app_name), + fontSize = (15 * logoSizeMultiplier).sp, + fontWeight = FontWeight.SemiBold, + color = customization.textColor, + letterSpacing = 0.3.sp + ) + } + } + } + } + } + } + } +} + +@Composable +fun ColorPresetSelector( + selectedPreset: ColorPreset, + onPresetChange: (ColorPreset) -> Unit, + modifier: Modifier = Modifier +) { + LazyRow( + modifier = modifier.padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(colorPresets) { preset -> + ColorPresetItem( + preset = preset, + isSelected = preset == selectedPreset, + onClick = { onPresetChange(preset) } + ) + } + } +} + +@Composable +private fun ColorPresetItem( + preset: ColorPreset, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val scale by animateFloatAsState( + targetValue = if (isSelected) 1.1f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) + ) + + Column( + modifier = modifier.clickable { onClick() }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(60.dp) + .scale(scale) + .clip(RoundedCornerShape(16.dp)) + .background( + brush = if (preset.customization.backgroundStyle == BackgroundStyle.GRADIENT + && preset.customization.gradientColors != null) { + Brush.linearGradient(preset.customization.gradientColors) + } else { + Brush.linearGradient( + listOf( + preset.customization.backgroundColor, + preset.customization.backgroundColor + ) + ) + } + ) + .border( + width = if (isSelected) 3.dp else 1.dp, + color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Gray.copy( + alpha = 0.3f + ), + shape = RoundedCornerShape(16.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "Aa", + color = preset.customization.textColor, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = preset.name, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = if (isSelected) 1f else 0.7f + ), + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/ShareLyricsDialog.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ShareLyricsDialog.kt new file mode 100644 index 00000000..bbca090f --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ShareLyricsDialog.kt @@ -0,0 +1,976 @@ +package com.darkxvenom.airbeats.ui.component + +import android.annotation.SuppressLint +import android.content.Intent +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.drawable.toBitmap +import androidx.palette.graphics.Palette +import coil.ImageLoader +import coil.request.ImageRequest +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.utils.ComposeToImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.min + +@SuppressLint("LocalContextGetResourceValueCall") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareLyricsDialog( + lyricsText: String, + songTitle: String, + artists: String, + mediaMetadata: MediaMetadata?, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { it != SheetValue.Hidden } + ) + + var showColorPickerSheet by remember { mutableStateOf(false) } + var showProgressDialog by remember { mutableStateOf(false) } + + if (!showColorPickerSheet) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + dragHandle = { BottomSheetDefaults.DragHandle() }, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) { + Column( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier.size(40.dp) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.media3_icon_share), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.share_lyrics), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { + Icon( + painter = painterResource(id = R.drawable.close), + contentDescription = stringResource(R.string.cancel), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "$songTitle • $artists", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp) + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + val songLink = "https://music.youtube.com/watch?v=${mediaMetadata?.id}" + putExtra(Intent.EXTRA_TEXT, "\"$lyricsText\"\n\n$songTitle - $artists\n$songLink") + } + context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.share_lyrics))) + onDismiss() + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Icon( + painter = painterResource(id = R.drawable.media3_icon_share), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.share_as_text), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + } + + OutlinedButton( + onClick = { showColorPickerSheet = true }, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.5.dp, MaterialTheme.colorScheme.outline), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface) + ) { + Icon( + painter = painterResource(id = R.drawable.image), + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(R.string.share_as_image), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.cancel), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + if (showColorPickerSheet) { + ShareLyricsImageCustomizationSheet( + lyricsText = lyricsText, + songTitle = songTitle, + artists = artists, + mediaMetadata = mediaMetadata, + onDismiss = { + showColorPickerSheet = false + onDismiss() + }, + onBack = { showColorPickerSheet = false }, + showProgressDialog = showProgressDialog, + onShowProgressDialog = { showProgressDialog = it } + ) + } + + if (showProgressDialog) { + ModalBottomSheet( + onDismissRequest = { }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) { + Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Text( + text = stringResource(R.string.generating_image) + "\n" + stringResource(R.string.please_wait), + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareLyricsImageCustomizationSheet( + lyricsText: String, + songTitle: String, + artists: String, + mediaMetadata: MediaMetadata?, + onDismiss: () -> Unit, + onBack: () -> Unit, + showProgressDialog: Boolean, + onShowProgressDialog: (Boolean) -> Unit +) { + val context = LocalContext.current + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val coverUrl = mediaMetadata?.thumbnailUrl + val paletteColors = remember { mutableStateListOf() } + + var selectedCustomization by remember { mutableStateOf(colorPresets[0].customization) } + var isPresetSelectorExpanded by remember { mutableStateOf(true) } + var isAdvancedSettingsExpanded by remember { mutableStateOf(false) } + var isLayoutSettingsExpanded by remember { mutableStateOf(false) } + var isTextSettingsExpanded by remember { mutableStateOf(false) } + var isStyleSettingsExpanded by remember { mutableStateOf(false) } + + LaunchedEffect(coverUrl) { + if (coverUrl != null) { + withContext(Dispatchers.IO) { + try { + val loader = ImageLoader(context) + val req = ImageRequest.Builder(context).data(coverUrl).allowHardware(false).build() + val drawable = loader.execute(req).drawable + if (drawable != null) { + val bitmap = drawable.toBitmap() + val palette = Palette.from(bitmap).generate() + val colors = listOfNotNull( + palette.getLightVibrantColor(Color.Black.toArgb()).takeIf { it != Color.Black.toArgb() }, + palette.getVibrantColor(Color.Black.toArgb()).takeIf { it != Color.Black.toArgb() }, + palette.getDarkVibrantColor(Color.Black.toArgb()).takeIf { it != Color.Black.toArgb() }, + palette.getLightMutedColor(Color.Black.toArgb()).takeIf { it != Color.Black.toArgb() }, + palette.getMutedColor(Color.Black.toArgb()).takeIf { it != Color.Black.toArgb() }, + palette.getDarkMutedColor(Color.Black.toArgb()).takeIf { it != Color.Black.toArgb() } + ).map { Color(it) }.distinct() + if (colors.isNotEmpty()) { + paletteColors.clear() + paletteColors.addAll(colors) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + dragHandle = { BottomSheetDefaults.DragHandle() }, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) + ) { + Column(modifier = Modifier.fillMaxWidth().fillMaxHeight(0.9f).verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { + Icon( + painter = painterResource(id = R.drawable.arrow_back), + contentDescription = stringResource(R.string.back), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.customize_image), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { + Icon( + painter = painterResource(id = R.drawable.close), + contentDescription = stringResource(R.string.cancel), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + + CustomizationCard( + title = stringResource(R.string.select_theme), + isExpanded = isPresetSelectorExpanded, + onExpandChange = { isPresetSelectorExpanded = it } + ) { + if (paletteColors.isNotEmpty()) { + Text( + text = stringResource(R.string.from_cover), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(bottom = 16.dp) + ) { + items(paletteColors) { color -> + val customization = ImageCustomization( + backgroundColor = color, + textColor = if (color.isDark()) Color.White else Color.Black, + secondaryTextColor = if (color.isDark()) Color.White.copy(alpha = 0.7f) else Color.Black.copy(alpha = 0.7f), + isDark = color.isDark() + ) + BottomSheetColorCustomizationItem( + customization = customization, + presetName = stringResource(R.string.cover_color), + isSelected = selectedCustomization.backgroundColor == customization.backgroundColor, + onClick = { selectedCustomization = customization } + ) + } + } + } + + Text( + text = stringResource(R.string.presets), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(colorPresets) { preset -> + BottomSheetColorCustomizationItem( + customization = preset.customization, + presetName = preset.name, + isSelected = selectedCustomization.backgroundColor == preset.customization.backgroundColor, + onClick = { selectedCustomization = preset.customization } + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + CustomizationCard( + title = "Style Settings", + isExpanded = isStyleSettingsExpanded, + onExpandChange = { isStyleSettingsExpanded = it } + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingSection(title = "Cover Art Style") { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = selectedCustomization.coverArtStyle == CoverArtStyle.ROUNDED, + onClick = { selectedCustomization = selectedCustomization.copy(coverArtStyle = CoverArtStyle.ROUNDED) }, + label = { Text("Rounded", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.coverArtStyle == CoverArtStyle.CIRCLE, + onClick = { selectedCustomization = selectedCustomization.copy(coverArtStyle = CoverArtStyle.CIRCLE) }, + label = { Text("Circle", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.coverArtStyle == CoverArtStyle.SQUARE, + onClick = { selectedCustomization = selectedCustomization.copy(coverArtStyle = CoverArtStyle.SQUARE) }, + label = { Text("Square", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + } + } + + SettingSection(title = "Lyrics Style") { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = selectedCustomization.lyricsStyle == LyricsStyle.NORMAL, + onClick = { selectedCustomization = selectedCustomization.copy(lyricsStyle = LyricsStyle.NORMAL) }, + label = { Text("Normal", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.lyricsStyle == LyricsStyle.ITALIC, + onClick = { selectedCustomization = selectedCustomization.copy(lyricsStyle = LyricsStyle.ITALIC) }, + label = { Text("Italic", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.lyricsStyle == LyricsStyle.CONDENSED, + onClick = { selectedCustomization = selectedCustomization.copy(lyricsStyle = LyricsStyle.CONDENSED) }, + label = { Text("Condensed", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + } + } + + SwitchSetting( + title = "Show Accent Line", + checked = selectedCustomization.showAccentLine, + onCheckedChange = { + selectedCustomization = selectedCustomization.copy( + showAccentLine = it, + accentColor = if (it && selectedCustomization.accentColor == null) + selectedCustomization.textColor else selectedCustomization.accentColor + ) + } + ) + + SliderSetting( + title = "Line Spacing", + value = selectedCustomization.lyricsLineSpacing, + valueRange = 1.0f..1.8f, + steps = 15, + onValueChange = { selectedCustomization = selectedCustomization.copy(lyricsLineSpacing = it) }, + valueLabel = String.format("%.1f", selectedCustomization.lyricsLineSpacing) + ) + + SliderSetting( + title = "Element Spacing", + value = selectedCustomization.spacingBetweenElements, + valueRange = 8f..32f, + steps = 23, + onValueChange = { selectedCustomization = selectedCustomization.copy(spacingBetweenElements = it) }, + valueLabel = "${selectedCustomization.spacingBetweenElements.toInt()}dp" + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + CustomizationCard( + title = "Background Settings", + isExpanded = isAdvancedSettingsExpanded, + onExpandChange = { isAdvancedSettingsExpanded = it } + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingSection(title = "Background Style") { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = selectedCustomization.backgroundStyle == BackgroundStyle.SOLID, + onClick = { selectedCustomization = selectedCustomization.copy(backgroundStyle = BackgroundStyle.SOLID) }, + label = { Text("Solid", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.backgroundStyle == BackgroundStyle.GRADIENT, + onClick = { selectedCustomization = selectedCustomization.copy(backgroundStyle = BackgroundStyle.GRADIENT) }, + label = { Text("Gradient", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.backgroundStyle == BackgroundStyle.PATTERN, + onClick = { selectedCustomization = selectedCustomization.copy(backgroundStyle = BackgroundStyle.PATTERN) }, + label = { Text("Pattern", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + } + } + + SliderSetting( + title = "Corner Radius", + value = selectedCustomization.cornerRadius, + valueRange = 0f..40f, + steps = 39, + onValueChange = { selectedCustomization = selectedCustomization.copy(cornerRadius = it) }, + valueLabel = "${selectedCustomization.cornerRadius.toInt()}dp" + ) + + if (selectedCustomization.backgroundStyle == BackgroundStyle.PATTERN) { + SliderSetting( + title = "Pattern Opacity", + value = selectedCustomization.patternOpacity, + valueRange = 0.01f..0.15f, + steps = 0, + onValueChange = { selectedCustomization = selectedCustomization.copy(patternOpacity = it) }, + valueLabel = "${(selectedCustomization.patternOpacity * 100).toInt()}%" + ) + } + + SwitchSetting( + title = "Border", + checked = selectedCustomization.borderEnabled, + onCheckedChange = { selectedCustomization = selectedCustomization.copy(borderEnabled = it) } + ) + + if (selectedCustomization.borderEnabled) { + SliderSetting( + title = "Border Width", + value = selectedCustomization.borderWidth, + valueRange = 1f..8f, + steps = 6, + onValueChange = { selectedCustomization = selectedCustomization.copy(borderWidth = it) }, + valueLabel = "${selectedCustomization.borderWidth.toInt()}dp" + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + CustomizationCard( + title = "Text Settings", + isExpanded = isTextSettingsExpanded, + onExpandChange = { isTextSettingsExpanded = it } + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingSection(title = "Font Style") { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = selectedCustomization.fontStyle == FontStyle.REGULAR, + onClick = { selectedCustomization = selectedCustomization.copy(fontStyle = FontStyle.REGULAR) }, + label = { Text("Regular", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.fontStyle == FontStyle.BOLD, + onClick = { selectedCustomization = selectedCustomization.copy(fontStyle = FontStyle.BOLD) }, + label = { Text("Bold", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.fontStyle == FontStyle.EXTRA_BOLD, + onClick = { selectedCustomization = selectedCustomization.copy(fontStyle = FontStyle.EXTRA_BOLD) }, + label = { Text("Extra Bold", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + } + } + + SettingSection(title = "Text Alignment") { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = selectedCustomization.textAlignment == TextAlignment.LEFT, + onClick = { selectedCustomization = selectedCustomization.copy(textAlignment = TextAlignment.LEFT) }, + label = { Text("Left", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.textAlignment == TextAlignment.CENTER, + onClick = { selectedCustomization = selectedCustomization.copy(textAlignment = TextAlignment.CENTER) }, + label = { Text("Center", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.textAlignment == TextAlignment.RIGHT, + onClick = { selectedCustomization = selectedCustomization.copy(textAlignment = TextAlignment.RIGHT) }, + label = { Text("Right", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + } + } + + SwitchSetting( + title = "Text Shadow", + checked = selectedCustomization.textShadowEnabled, + onCheckedChange = { selectedCustomization = selectedCustomization.copy(textShadowEnabled = it) } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + CustomizationCard( + title = "Layout Settings", + isExpanded = isLayoutSettingsExpanded, + onExpandChange = { isLayoutSettingsExpanded = it } + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SwitchSetting( + title = "Show Cover Art", + checked = selectedCustomization.showCoverArt, + onCheckedChange = { selectedCustomization = selectedCustomization.copy(showCoverArt = it) } + ) + + if (selectedCustomization.showCoverArt) { + SwitchSetting( + title = "Show Song Title", + checked = selectedCustomization.showSongTitle, + onCheckedChange = { selectedCustomization = selectedCustomization.copy(showSongTitle = it) } + ) + + SwitchSetting( + title = "Show Artist Name", + checked = selectedCustomization.showArtistName, + onCheckedChange = { selectedCustomization = selectedCustomization.copy(showArtistName = it) } + ) + } + + SwitchSetting( + title = "Show Logo", + checked = selectedCustomization.showLogo, + onCheckedChange = { selectedCustomization = selectedCustomization.copy(showLogo = it) } + ) + + if (selectedCustomization.showLogo) { + SettingSection(title = "Logo Position") { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = selectedCustomization.logoPosition == LogoPosition.BOTTOM_LEFT, + onClick = { selectedCustomization = selectedCustomization.copy(logoPosition = LogoPosition.BOTTOM_LEFT) }, + label = { Text("Bottom Left", fontSize = 11.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.logoPosition == LogoPosition.BOTTOM_RIGHT, + onClick = { selectedCustomization = selectedCustomization.copy(logoPosition = LogoPosition.BOTTOM_RIGHT) }, + label = { Text("Bottom Right", fontSize = 11.sp) }, + modifier = Modifier.weight(1f) + ) + } + } + + SettingSection(title = "Logo Size") { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = selectedCustomization.logoSize == LogoSize.SMALL, + onClick = { selectedCustomization = selectedCustomization.copy(logoSize = LogoSize.SMALL) }, + label = { Text("Small", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.logoSize == LogoSize.MEDIUM, + onClick = { selectedCustomization = selectedCustomization.copy(logoSize = LogoSize.MEDIUM) }, + label = { Text("Medium", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + FilterChip( + selected = selectedCustomization.logoSize == LogoSize.LARGE, + onClick = { selectedCustomization = selectedCustomization.copy(logoSize = LogoSize.LARGE) }, + label = { Text("Large", fontSize = 12.sp) }, + modifier = Modifier.weight(1f) + ) + } + } + } + + SliderSetting( + title = "Padding", + value = selectedCustomization.padding, + valueRange = 16f..64f, + steps = 47, + onValueChange = { selectedCustomization = selectedCustomization.copy(padding = it) }, + valueLabel = "${selectedCustomization.padding.toInt()}dp" + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.preview), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 24.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + contentAlignment = Alignment.Center + ) { + if (mediaMetadata != null) { + val previewSize = remember(maxWidth) { min(maxWidth.value - 48f, 380f).dp } + Box(modifier = Modifier.size(previewSize), contentAlignment = Alignment.Center) { + LyricsImageCardPreview( + lyricText = lyricsText, + mediaMetadata = mediaMetadata, + customization = selectedCustomization, + modifier = Modifier.fillMaxSize() + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onBack, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = stringResource(R.string.back), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium + ) + } + + Button( + onClick = { + scope.launch { + onShowProgressDialog(true) + try { + val screenWidth = configuration.screenWidthDp + val screenHeight = configuration.screenHeightDp + + val image = ComposeToImage.createLyricsImage( + context = context, + coverArtUrl = coverUrl, + songTitle = songTitle, + artistName = artists, + lyrics = lyricsText, + width = (screenWidth * density.density).toInt(), + height = (screenHeight * density.density).toInt(), + backgroundColor = selectedCustomization.backgroundColor.toArgb(), + textColor = selectedCustomization.textColor.toArgb(), + secondaryTextColor = selectedCustomization.secondaryTextColor.toArgb(), + showCoverArt = selectedCustomization.showCoverArt, + showLogo = selectedCustomization.showLogo, + backgroundStyle = selectedCustomization.backgroundStyle.name, + gradientColors = selectedCustomization.gradientColors?.map { it.toArgb() }?.toIntArray(), + fontStyle = selectedCustomization.fontStyle.name, + logoPosition = selectedCustomization.logoPosition.name, + cornerRadius = selectedCustomization.cornerRadius, + patternOpacity = selectedCustomization.patternOpacity, + textAlignment = selectedCustomization.textAlignment.name, + padding = selectedCustomization.padding, + showArtistName = selectedCustomization.showArtistName, + showSongTitle = selectedCustomization.showSongTitle, + textShadowEnabled = selectedCustomization.textShadowEnabled, + borderEnabled = selectedCustomization.borderEnabled, + borderColor = selectedCustomization.borderColor.toArgb(), + borderWidth = selectedCustomization.borderWidth, + logoSize = selectedCustomization.logoSize.name, + coverArtStyle = selectedCustomization.coverArtStyle.name, + lyricsStyle = selectedCustomization.lyricsStyle.name, + showAccentLine = selectedCustomization.showAccentLine, + accentColor = selectedCustomization.accentColor?.toArgb(), + spacingBetweenElements = selectedCustomization.spacingBetweenElements, + lyricsLineSpacing = selectedCustomization.lyricsLineSpacing + ) + + val timestamp = System.currentTimeMillis() + val safeArtistName = artists.replace("[^a-zA-Z0-9\\s]".toRegex(), "").trim().replace("\\s+".toRegex(), "_") + val safeSongTitle = songTitle.replace("[^a-zA-Z0-9\\s]".toRegex(), "").trim().replace("\\s+".toRegex(), "_") + + val filename = if (safeArtistName.isNotEmpty() && safeSongTitle.isNotEmpty()) { + "lyrics_${safeArtistName}_${safeSongTitle}_$timestamp" + } else if (safeSongTitle.isNotEmpty()) { + "lyrics_${safeSongTitle}_$timestamp" + } else { + "lyrics_$timestamp" + } + + val uri = ComposeToImage.saveBitmapAsFile(context, image, filename) + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "image/png" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(Intent.createChooser(shareIntent, "Share Lyrics")) + + onShowProgressDialog(false) + onDismiss() + + } catch (e: Exception) { + Toast.makeText(context, "Failed to create image: ${e.message}", Toast.LENGTH_SHORT).show() + onShowProgressDialog(false) + } + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Text( + text = stringResource(R.string.share_lyrics_image), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun CustomizationCard( + title: String, + isExpanded: Boolean, + onExpandChange: (Boolean) -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + + IconButton(onClick = { onExpandChange(!isExpanded) }, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, + contentDescription = if (isExpanded) stringResource(R.string.collapse) else stringResource(R.string.expand), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + AnimatedVisibility( + visible = isExpanded, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut() + ) { + Column(modifier = Modifier.padding(top = 16.dp), content = content) + } + } + } +} + +@Composable +private fun SettingSection(title: String, content: @Composable () -> Unit) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + content() + } +} + +@Composable +private fun SliderSetting( + title: String, + value: Float, + valueRange: ClosedFloatingPointRange, + steps: Int, + onValueChange: (Float) -> Unit, + valueLabel: String +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "$title: $valueLabel", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + Slider(value = value, onValueChange = onValueChange, valueRange = valueRange, steps = steps) + } +} + +@Composable +private fun SwitchSetting(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +fun Color.isDark(): Boolean { + val luminance = 0.299f * this.red + 0.587f * this.green + 0.114f * this.blue + return luminance < 0.5f +} + +@Composable +fun BottomSheetColorCustomizationItem( + customization: ImageCustomization, + presetName: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val scale by animateFloatAsState( + targetValue = if (isSelected) 1.1f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + label = "scale" + ) + + Column( + modifier = modifier.width(60.dp).clickable { onClick() }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.size(48.dp).scale(scale).clip(RoundedCornerShape(12.dp)) + .background( + brush = if (customization.backgroundStyle == BackgroundStyle.GRADIENT && customization.gradientColors != null) { + Brush.linearGradient(customization.gradientColors) + } else { + Brush.linearGradient(listOf(customization.backgroundColor, customization.backgroundColor)) + } + ) + .border( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.3f), + shape = RoundedCornerShape(12.dp) + ), + contentAlignment = Alignment.Center + ) { + Text(text = "Aa", color = customization.textColor, fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = presetName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (isSelected) 1f else 0.7f), + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(60.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/SortHeader.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SortHeader.kt new file mode 100644 index 00000000..37380814 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/SortHeader.kt @@ -0,0 +1,110 @@ +package com.darkxvenom.airbeats.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.PlaylistSongSortType + +@Composable +inline fun > SortHeader( + sortType: T, + sortDescending: Boolean, + crossinline onSortTypeChange: (T) -> Unit, + crossinline onSortDescendingChange: (Boolean) -> Unit, + crossinline sortTypeText: (T) -> Int, + modifier: Modifier = Modifier, + showDescending: Boolean? = true, +) { + var menuExpanded by remember { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(vertical = 8.dp), + ) { + Text( + text = stringResource(sortTypeText(sortType)), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + ) { + menuExpanded = !menuExpanded + } + .padding(horizontal = 4.dp, vertical = 8.dp), + ) + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + modifier = Modifier.widthIn(min = 172.dp), + ) { + enumValues().forEach { type -> + DropdownMenuItem( + text = { + Text( + text = stringResource(sortTypeText(type)), + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + ) + }, + trailingIcon = { + Icon( + painter = + painterResource( + if (sortType == + type + ) { + R.drawable.radio_button_checked + } else { + R.drawable.radio_button_unchecked + }, + ), + contentDescription = null, + ) + }, + onClick = { + onSortTypeChange(type) + menuExpanded = false + }, + ) + } + } + + if (sortType != PlaylistSongSortType.CUSTOM && showDescending == true) { + ResizableIconButton( + icon = if (sortDescending) R.drawable.arrow_downward else R.drawable.arrow_upward, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .size(32.dp) + .padding(8.dp), + onClick = { onSortDescendingChange(!sortDescending) }, + ) + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/ThumbnailCornerRadiusSelector.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ThumbnailCornerRadiusSelector.kt new file mode 100644 index 00000000..0870cb28 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/ThumbnailCornerRadiusSelector.kt @@ -0,0 +1,528 @@ +package com.darkxvenom.airbeats.ui.component + +import android.content.Context +import androidx.compose.foundation.Image +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.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +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 androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.darkxvenom.airbeats.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + + +// Extensión de contexto para DataStore +val Context.dataStore: DataStore by preferencesDataStore(name = "app_settings") + +object AppConfig { + private val THUMBNAIL_CORNER_RADIUS_KEY = floatPreferencesKey("thumbnail_corner_radius") + + // Guardar el valor de thumbnail corner radius + suspend fun saveThumbnailCornerRadius(context: Context, radius: Float) { + context.dataStore.edit { preferences -> + preferences[THUMBNAIL_CORNER_RADIUS_KEY] = radius + } + } + + // Obtener el valor de thumbnail corner radius, o un valor por defecto si no está presente + suspend fun getThumbnailCornerRadius(context: Context, defaultValue: Float = 16f): Float { + return context.dataStore.data + .map { preferences -> + preferences[THUMBNAIL_CORNER_RADIUS_KEY] ?: defaultValue + }.first() + } +} + + +@Composable +fun ThumbnailCornerRadiusSelectorButton( + modifier: Modifier = Modifier, + onRadiusSelected: (Float) -> Unit +) { + val context = LocalContext.current + var showDialog by remember { mutableStateOf(false) } + var currentRadius by remember { mutableStateOf(24f) } + + // Cargar el valor guardado al iniciar + LaunchedEffect(Unit) { + currentRadius = AppConfig.getThumbnailCornerRadius(context) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .padding(horizontal = 20.dp, vertical = 16.dp) + .clickable( + enabled = true, + onClick = { showDialog = true } + ), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.line_curve), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource( + id = R.string.customize_thumbnail_corner_radius, + currentRadius.roundToInt() + ), + + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } +// } + + // Modal para seleccionar el radio + if (showDialog) { + ThumbnailCornerRadiusModal( + initialRadius = currentRadius, + onDismiss = { showDialog = false }, + onRadiusSelected = { newRadius -> + currentRadius = newRadius + onRadiusSelected(newRadius) + showDialog = false + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThumbnailCornerRadiusModal( + initialRadius: Float, + onDismiss: () -> Unit, + onRadiusSelected: (Float) -> Unit +) { + val context = LocalContext.current + var thumbnailCornerRadius by remember { mutableStateOf(initialRadius) } + val presetValues = listOf(0f, 8f, 16f, 24f, 32f, 40f) + var customValue by remember { mutableStateOf("") } + var isCustomSelected by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + isCustomSelected = !presetValues.contains(initialRadius) + if (isCustomSelected) { + customValue = initialRadius.roundToInt().toString() + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + + BoxWithConstraints { + val screenWidth = maxWidth + val dialogWidth = when { + screenWidth > 840.dp -> 0.5f + screenWidth > 600.dp -> 0.7f + else -> 0.95f + } + + Surface( + modifier = Modifier + .fillMaxWidth(dialogWidth) + .wrapContentHeight() + .clip(RoundedCornerShape(30.dp)) + .padding(16.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Spacer(modifier = Modifier.height(16.dp)) + + // Previsualización con imagen de fondo + Box( + modifier = Modifier + .size(160.dp) + .clip(RoundedCornerShape(thumbnailCornerRadius.dp)) + .background(MaterialTheme.colorScheme.surface) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), + shape = RoundedCornerShape(thumbnailCornerRadius.dp) + ), + contentAlignment = Alignment.Center + ) { + // Imagen de ejemplo con bordes redondeados + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(thumbnailCornerRadius.dp)) + ) { + // Usando un painterResource para cargar una imagen local + Image( + painter = painterResource(id = R.drawable.previewalbum), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + + // Overlay para mostrar el valor del radio + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)) + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "${thumbnailCornerRadius.roundToInt()}dp", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Chips con Layout mejorado y adaptable usando un enfoque alternativo a FlowRow + ChipsGrid( + values = presetValues, + selectedValue = if (isCustomSelected) null else thumbnailCornerRadius, + onValueSelected = { value -> + thumbnailCornerRadius = value + isCustomSelected = false + } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Campo personalizado con mejor disposición + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + FilterChip( + selected = isCustomSelected, + onClick = { + isCustomSelected = true + if (customValue.isEmpty()) { + customValue = thumbnailCornerRadius.roundToInt().toString() + } + }, + label = { + Text( + text = stringResource(id = R.string.custom), + style = MaterialTheme.typography.bodyMedium + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + OutlinedTextField( + value = customValue, + onValueChange = { newValue -> + // Solo aceptar números + if (newValue.isEmpty() || newValue.all { it.isDigit() }) { + customValue = newValue + newValue.toIntOrNull()?.let { intValue -> + // Limitar a 45 + val limitedValue = minOf(intValue, 45).toFloat() + thumbnailCornerRadius = limitedValue + isCustomSelected = true + } + } + }, + modifier = Modifier + .weight(1f) + .height(56.dp), + enabled = isCustomSelected, + label = { + Text( + text = stringResource(id = R.string.custom_value), + style = MaterialTheme.typography.bodyMedium + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + trailingIcon = { + Text( + text = "dp", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + shape = MaterialTheme.shapes.small + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Sección del slider mejorada para más feedback + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(id = R.string.adjust_radius), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "0", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Slider( + value = thumbnailCornerRadius, + onValueChange = { newValue -> + thumbnailCornerRadius = newValue + customValue = newValue.roundToInt().toString() + isCustomSelected = !presetValues.contains(newValue) + }, + valueRange = 0f..45f, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + + Text( + text = "45", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Text( + text = stringResource( + id = R.string.corner_radius_label, + thumbnailCornerRadius.roundToInt().toString() + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Divisor para separar visualmente los botones de acción + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Botones de acción con mejor UX + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onDismiss, + modifier = Modifier.heightIn(min = 48.dp) + ) { + Text( + text = stringResource(id = R.string.cancel), + style = MaterialTheme.typography.labelLarge + ) + } + + Button( + onClick = { + CoroutineScope(Dispatchers.IO).launch { + AppConfig.saveThumbnailCornerRadius( + context, + thumbnailCornerRadius + ) + } + onRadiusSelected(thumbnailCornerRadius) + }, + modifier = Modifier.heightIn(min = 48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.check), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(id = R.string.apply), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChipsGrid( + values: List, + selectedValue: Float?, + onValueSelected: (Float) -> Unit +) { + val chunkedValues = values.chunked(3) // Divide en filas de 3 elementos + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + chunkedValues.forEach { rowValues -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + rowValues.forEachIndexed { index, value -> + FilterChip( + selected = selectedValue == value, + onClick = { onValueSelected(value) }, + label = { + Text( + text = "${value.roundToInt()}", + style = MaterialTheme.typography.bodyMedium + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + + if (index < rowValues.size - 1) { + Spacer(modifier = Modifier.width(8.dp)) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/VerticalFastScroller.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/VerticalFastScroller.kt new file mode 100644 index 00000000..e770c76d --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/VerticalFastScroller.kt @@ -0,0 +1,377 @@ +package com.darkxvenom.airbeats.ui.component + +import android.view.ViewConfiguration +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material3.MaterialTheme +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.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +@Composable +fun VerticalFastScroller( + listState: LazyListState, + thumbColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + topContentPadding: Dp = Dp.Hairline, + endContentPadding: Dp = Dp.Hairline, + content: @Composable () -> Unit, +) { + SubcomposeLayout { constraints -> + val contentPlaceable = subcompose("content", content).map { it.measure(constraints) } + val contentHeight = contentPlaceable.maxByOrNull { it.height }?.height ?: 0 + val contentWidth = contentPlaceable.maxByOrNull { it.width }?.width ?: 0 + + val scrollerPlaceable = subcompose("scroller") { + val layoutInfo = listState.layoutInfo + val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount + if (!showScroller) return@subcompose + + val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() } + var thumbOffsetY by remember(thumbTopPadding) { mutableStateOf(thumbTopPadding) } + + val dragInteractionSource = remember { MutableInteractionSource() } + val isThumbDragged by dragInteractionSource.collectIsDraggedAsState() + val scrolled = remember { + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + } + + val heightPx = + contentHeight.toFloat() - thumbTopPadding - listState.layoutInfo.afterContentPadding + val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() } + val trackHeightPx = heightPx - thumbHeightPx + + // Haptic feedback + val hapticFeedback = LocalHapticFeedback.current + + // Animaciones mejoradas para Material Design 3 + val thumbScale = remember { Animatable(1f) } + val thumbElevation = remember { Animatable(3f) } // Elevación base + + // Colores animados para transiciones suaves + val thumbColorAnimated by animateColorAsState( + targetValue = if (isThumbDragged) { + MaterialTheme.colorScheme.primary + } else { + thumbColor + }, + animationSpec = tween(300), + label = "thumb_color" + ) + + // Animación de grosor del track + val trackAlphaAnimated by animateFloatAsState( + targetValue = if (isThumbDragged) 0.5f else 0.3f, + animationSpec = tween(300), + label = "track_alpha" + ) + + // Efectos de interacción mejorados con más dinamismo + LaunchedEffect(isThumbDragged) { + if (isThumbDragged) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + thumbScale.animateTo( + targetValue = 1.3f, // Más escala para mejor feedback + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessHigh + ) + ) + thumbElevation.animateTo( + targetValue = 12f, // Mayor elevación + animationSpec = spring(stiffness = Spring.StiffnessHigh) + ) + } else { + thumbScale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + thumbElevation.animateTo( + targetValue = 3f, // Elevación base más pronunciada + animationSpec = spring(stiffness = Spring.StiffnessMedium) + ) + } + } + + // When thumb dragged - Mejorado para mejor precisión + LaunchedEffect(thumbOffsetY) { + if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect + val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx + val scrollItem = layoutInfo.totalItemsCount * scrollRatio + val scrollItemRounded = scrollItem.roundToInt().coerceIn(0, layoutInfo.totalItemsCount - 1) + + // Mejora: Cálculo más preciso del offset dentro del item + val scrollItemSize = layoutInfo.visibleItemsInfo + .find { it.index == scrollItemRounded }?.size + ?: run { + // Fallback: usar tamaño promedio si el item no está visible + val averageItemSize = layoutInfo.visibleItemsInfo + .takeIf { it.isNotEmpty() } + ?.map { it.size } + ?.average()?.toInt() + ?: 200 // Valor por defecto razonable + averageItemSize + } + + val scrollItemOffset = (scrollItemSize * (scrollItem - scrollItemRounded)) + .coerceIn(0f, scrollItemSize.toFloat()) + + try { + listState.scrollToItem( + index = scrollItemRounded, + scrollOffset = scrollItemOffset.roundToInt() + ) + scrolled.tryEmit(Unit) + } catch (e: Exception) { + // Fallback silencioso si hay error de scroll + } + } + + // When list scrolled - Optimizado para máxima fluidez + LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { + if (listState.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect + + // Usar snapshotFlow para mejor rendimiento + snapshotFlow { + Triple( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset, + listState.layoutInfo.totalItemsCount + ) + }.collect { (_, _, totalItems) -> + if (totalItems == 0) return@collect + + val scrollOffset = computeScrollOffset(state = listState) + val scrollRange = computeScrollRange(state = listState) + + if (scrollRange > heightPx) { + val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx) + val newThumbOffset = (trackHeightPx * proportion.coerceIn(0f, 1f) + thumbTopPadding) + + // Interpolación suave para reducir jank + val smoothFactor = 0.8f + thumbOffsetY = thumbOffsetY * (1f - smoothFactor) + newThumbOffset * smoothFactor + scrolled.tryEmit(Unit) + } + } + } + + // Thumb alpha con transición más suave y inteligente + val alpha = remember { Animatable(0f) } + val isThumbVisible = alpha.value > 0f + + // Detección de velocidad de scroll para mejor UX + var lastScrollTime by remember { mutableStateOf(0L) } + var scrollVelocity by remember { mutableStateOf(0f) } + + LaunchedEffect(scrolled, alpha) { + scrolled.collectLatest { + val currentTime = System.currentTimeMillis() + scrollVelocity = if (lastScrollTime > 0) { + 1000f / (currentTime - lastScrollTime).coerceAtLeast(1) + } else 0f + lastScrollTime = currentTime + + alpha.snapTo(1f) + // Tiempo de fade adaptativo según velocidad + val fadeDelay = if (scrollVelocity > 10f) 3000 else 2000 + alpha.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), + delayMillis = fadeDelay + ) + ) + } + } + + // Indicador de track mejorado con animaciones + Box( + modifier = Modifier + .offset { IntOffset(0, thumbTopPadding.roundToInt()) } + .height(with(LocalDensity.current) { trackHeightPx.toDp() }) + .padding(horizontal = 8.dp) + .padding(end = endContentPadding) + .width(TrackThickness) + .alpha(alpha.value * trackAlphaAnimated) + .background( + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + shape = RoundedCornerShape(TrackThickness / 2) + ) + ) + + // Thumb principal mejorado con más características + Box( + modifier = Modifier + .offset { IntOffset(0, thumbOffsetY.roundToInt()) } + .height(ThumbLength) + .then( + // Exclude thumb from gesture area only when needed + if (isThumbVisible && !isThumbDragged && !listState.isScrollInProgress) { + Modifier.systemGestureExclusion() + } else Modifier, + ) + .padding(horizontal = 6.dp) // Menos padding para thumb más ancho + .padding(end = endContentPadding) + .width(ThumbThickness) + .scale(thumbScale.value) + .alpha(alpha.value) + .shadow( + elevation = thumbElevation.value.dp, + shape = ThumbShape, + clip = false, + spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + ) + .background( + color = thumbColorAnimated, + shape = ThumbShape + ) + .then( + // Recompose opts + if (!listState.isScrollInProgress) { + Modifier.draggable( + interactionSource = dragInteractionSource, + orientation = Orientation.Vertical, + enabled = isThumbVisible, + state = rememberDraggableState { delta -> + val sensitivity = 1.2f // Factor de sensibilidad ajustable + val adjustedDelta = delta * sensitivity + + val newOffsetY = thumbOffsetY + adjustedDelta + thumbOffsetY = newOffsetY.coerceIn( + thumbTopPadding, + thumbTopPadding + trackHeightPx + ) + + // Haptic feedback más inteligente + if (abs(adjustedDelta) > 3f) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + }, + ) + } else Modifier, + ), + ) + }.map { it.measure(constraints.copy(minWidth = 0, minHeight = 0)) } + val scrollerWidth = scrollerPlaceable.maxByOrNull { it.width }?.width ?: 0 + + layout(contentWidth, contentHeight) { + contentPlaceable.forEach { + it.placeRelative(0, 0) + } + scrollerPlaceable.forEach { + it.placeRelative(contentWidth - scrollerWidth, 0) + } + } + } +} + +private fun computeScrollOffset(state: LazyListState): Int { + if (state.layoutInfo.totalItemsCount == 0) return 0 + val visibleItems = state.layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) return 0 + + val startChild = visibleItems.first() + val endChild = visibleItems.last() + val minPosition = min(startChild.index, endChild.index) + val maxPosition = max(startChild.index, endChild.index) + val itemsBefore = minPosition.coerceAtLeast(0) + val startDecoratedTop = startChild.top + val laidOutArea = abs(endChild.bottom - startDecoratedTop) + val itemRange = abs(minPosition - maxPosition) + 1 + + // Mejora: Manejo más robusto de divisiones por cero + val avgSizePerRow = if (itemRange > 0 && laidOutArea > 0) { + laidOutArea.toFloat() / itemRange + } else { + 200f // Valor por defecto + } + + return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt().coerceAtLeast(0) +} + +private fun computeScrollRange(state: LazyListState): Int { + if (state.layoutInfo.totalItemsCount == 0) return 0 + val visibleItems = state.layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) return 0 + + val startChild = visibleItems.first() + val endChild = visibleItems.last() + val laidOutArea = endChild.bottom - startChild.top + val laidOutRange = abs(startChild.index - endChild.index) + 1 + + // Mejora: Cálculo más preciso del rango total + val averageItemSize = if (laidOutRange > 0 && laidOutArea > 0) { + laidOutArea.toFloat() / laidOutRange + } else { + 200f // Valor por defecto + } + + val totalRange = (averageItemSize * state.layoutInfo.totalItemsCount).roundToInt() + + // Añadir padding si existe + return totalRange + state.layoutInfo.beforeContentPadding + state.layoutInfo.afterContentPadding +} + +// Valores mejorados siguiendo Material Design 3 +private val ThumbLength = 48.dp +private val ThumbThickness = 12.dp // Más ancho para mejor agarre +private val TrackThickness = 6.dp +private val ThumbShape = RoundedCornerShape(ThumbThickness / 2) +private val FadeOutAnimationSpec = tween( + durationMillis = ViewConfiguration.getScrollBarFadeDuration(), + delayMillis = 2000, +) + +private val LazyListItemInfo.top: Int + get() = offset + +private val LazyListItemInfo.bottom: Int + get() = offset + size \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ButtonPlaceholder.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ButtonPlaceholder.kt new file mode 100644 index 00000000..54ad8bf7 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ButtonPlaceholder.kt @@ -0,0 +1,21 @@ +package com.darkxvenom.airbeats.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip + +@Composable +fun ButtonPlaceholder(modifier: Modifier = Modifier) { + Spacer( + modifier + .height(ButtonDefaults.MinHeight) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.onSurface), + ) +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/GridItemPlaceholder.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/GridItemPlaceholder.kt new file mode 100644 index 00000000..880975a4 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/GridItemPlaceholder.kt @@ -0,0 +1,57 @@ +package com.darkxvenom.airbeats.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius + +@Composable +fun GridItemPlaceHolder( + modifier: Modifier = Modifier, + thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth: Boolean = false, +) { + Column( + modifier = + if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight) + }, + ) { + Spacer( + modifier = + if (fillMaxWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.height(GridThumbnailHeight) + } + .aspectRatio(1f) + .clip(thumbnailShape) + .background(MaterialTheme.colorScheme.onSurface), + ) + + Spacer(modifier = Modifier.height(6.dp)) + + TextPlaceholder() + + TextPlaceholder() + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ListItemPlaceholder.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ListItemPlaceholder.kt new file mode 100644 index 00000000..2f216bcd --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ListItemPlaceholder.kt @@ -0,0 +1,53 @@ +package com.darkxvenom.airbeats.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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 com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius + +@Composable +fun ListItemPlaceHolder( + modifier: Modifier = Modifier, + thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius), +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .height(ListItemHeight) + .padding(horizontal = 6.dp), + ) { + Spacer( + modifier = + Modifier + .padding(6.dp) + .size(ListThumbnailSize) + .clip(thumbnailShape) + .background(MaterialTheme.colorScheme.onSurface), + ) + + Column( + modifier = + Modifier + .weight(1f) + .padding(horizontal = 6.dp), + ) { + TextPlaceholder() + TextPlaceholder() + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ShimmerHost.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ShimmerHost.kt new file mode 100644 index 00000000..e36da41c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/ShimmerHost.kt @@ -0,0 +1,64 @@ +package com.darkxvenom.airbeats.ui.component.shimmer + +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.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +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 +import com.valentinilk.shimmer.defaultShimmerTheme +import com.valentinilk.shimmer.shimmer + +@Composable +fun ShimmerHost( + modifier: Modifier = Modifier, + 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, + ) +} + +val ShimmerTheme = + defaultShimmerTheme.copy( + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = 800, + easing = LinearEasing, + delayMillis = 250, + ), + repeatMode = RepeatMode.Restart, + ), + shaderColors = + listOf( + Color.Unspecified.copy(alpha = 0.25f), + Color.Unspecified.copy(alpha = 0.50f), + Color.Unspecified.copy(alpha = 0.25f), + ), + ) diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/TextPlaceholder.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/TextPlaceholder.kt new file mode 100644 index 00000000..7fae4626 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/component/shimmer/TextPlaceholder.kt @@ -0,0 +1,29 @@ +package com.darkxvenom.airbeats.ui.component.shimmer + +import androidx.compose.foundation.background +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.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.random.Random + +@Composable +fun TextPlaceholder( + modifier: Modifier = Modifier, + height: Dp = 16.dp, +) { + Spacer( + modifier = + modifier + .padding(vertical = 4.dp) + .background(MaterialTheme.colorScheme.onSurface) + .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) + .height(height), + ) +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/AddToPlaylistDialog.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/AddToPlaylistDialog.kt new file mode 100644 index 00000000..7ffa467f --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/AddToPlaylistDialog.kt @@ -0,0 +1,195 @@ +package com.darkxvenom.airbeats.ui.menu + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.utils.parseCookieString +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.InnerTubeCookieKey +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.ui.component.CreatePlaylistDialog +import com.darkxvenom.airbeats.ui.component.DefaultDialog +import com.darkxvenom.airbeats.ui.component.ListDialog +import com.darkxvenom.airbeats.ui.component.ListItem +import com.darkxvenom.airbeats.ui.component.PlaylistListItem +import com.darkxvenom.airbeats.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun AddToPlaylistDialog( + isVisible: Boolean, + allowSyncing: Boolean = true, + initialTextFieldValue: String? = null, + onGetSong: suspend (Playlist) -> List, // list of song ids. Songs should be inserted to database in this function. + onDismiss: () -> Unit, +) { + val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + var playlists by remember { + mutableStateOf(emptyList()) + } + val (innerTubeCookie) = rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + var showCreatePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var showDuplicateDialog by remember { + mutableStateOf(false) + } + var selectedPlaylist by remember { + mutableStateOf(null) + } + var songIds by remember { + mutableStateOf?>(null) // list is not saveable + } + var duplicates by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + database.editablePlaylistsByCreateDateAsc().collect { + playlists = it.asReversed() + } + } + + if (isVisible) { + ListDialog( + onDismiss = onDismiss, + ) { + item { + ListItem( + title = stringResource(R.string.create_playlist), + thumbnailContent = { + Image( + painter = painterResource(R.drawable.add), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(ListThumbnailSize) + ) + }, + modifier = Modifier.clickable { + showCreatePlaylistDialog = true + } + ) + } + + items(playlists) { playlist -> + PlaylistListItem( + playlist = playlist, + modifier = Modifier.clickable { + selectedPlaylist = playlist + coroutineScope.launch(Dispatchers.IO) { + if (songIds == null) { + songIds = onGetSong(playlist) + } + duplicates = database.playlistDuplicates(playlist.id, songIds!!) + if (duplicates.isNotEmpty()) { + showDuplicateDialog = true + } else { + onDismiss() + database.addSongToPlaylist(playlist, songIds!!) + + playlist.playlist.browseId?.let { plist -> + songIds?.forEach { + YouTube.addToPlaylist(plist, it) + } + } + } + } + } + ) + } + } + } + + if (showCreatePlaylistDialog) { + CreatePlaylistDialog( + onDismiss = { showCreatePlaylistDialog = false }, + initialTextFieldValue = initialTextFieldValue, + allowSyncing = allowSyncing + ) + } + + // duplicate songs warning + if (showDuplicateDialog) { + DefaultDialog( + title = { Text(stringResource(R.string.duplicates)) }, + buttons = { + TextButton( + onClick = { + showDuplicateDialog = false + onDismiss() + database.transaction { + addSongToPlaylist( + selectedPlaylist!!, + songIds!!.filter { + !duplicates.contains(it) + } + ) + } + } + ) { + Text(stringResource(R.string.skip_duplicates)) + } + + TextButton( + onClick = { + showDuplicateDialog = false + onDismiss() + database.transaction { + addSongToPlaylist(selectedPlaylist!!, songIds!!) + } + } + ) { + Text(stringResource(R.string.add_anyway)) + } + + TextButton( + onClick = { + showDuplicateDialog = false + } + ) { + Text(stringResource(android.R.string.cancel)) + } + }, + onDismiss = { + showDuplicateDialog = false + } + ) { + Text( + text = if (duplicates.size == 1) { + stringResource(R.string.duplicates_description_single) + } else { + stringResource(R.string.duplicates_description_multiple, duplicates.size) + }, + textAlign = TextAlign.Start, + modifier = Modifier.align(Alignment.Start) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/AlbumMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/AlbumMenu.kt new file mode 100644 index 00000000..83fae1ed --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/AlbumMenu.kt @@ -0,0 +1,375 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.annotation.SuppressLint +import android.content.Intent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +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.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING +import androidx.media3.exoplayer.offline.Download.STATE_QUEUED +import androidx.media3.exoplayer.offline.Download.STATE_STOPPED +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.ui.component.AlbumListItem +import com.darkxvenom.airbeats.ui.component.DownloadGridMenu +import com.darkxvenom.airbeats.ui.component.GridMenu +import com.darkxvenom.airbeats.ui.component.GridMenuItem +import com.darkxvenom.airbeats.ui.component.ListDialog +import com.darkxvenom.airbeats.ui.component.ListItem +import com.darkxvenom.airbeats.ui.component.SongListItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@SuppressLint("MutableCollectionMutableState") +@Composable +fun AlbumMenu( + originalAlbum: Album, + navController: NavController, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + val scope = rememberCoroutineScope() + val libraryAlbum by database.album(originalAlbum.id).collectAsState(initial = originalAlbum) + val album = libraryAlbum ?: originalAlbum + var songs by remember { + mutableStateOf(emptyList()) + } + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + database.albumSongs(album.id).collect { + songs = it + } + } + + var downloadState by remember { + mutableStateOf(STATE_STOPPED) + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == STATE_COMPLETED }) { + STATE_COMPLETED + } else if (songs.all { + downloads[it.id]?.state == STATE_QUEUED || + downloads[it.id]?.state == STATE_DOWNLOADING || + downloads[it.id]?.state == STATE_COMPLETED + } + ) { + STATE_DOWNLOADING + } else { + STATE_STOPPED + } + } + } + + var refetchIconDegree by remember { mutableFloatStateOf(0f) } + + val rotationAnimation by animateFloatAsState( + targetValue = refetchIconDegree, + animationSpec = tween(durationMillis = 800), + label = "", + ) + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + var showErrorPlaylistAddDialog by rememberSaveable { + mutableStateOf(false) + } + + val notAddedList by remember { + mutableStateOf(mutableListOf()) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { playlistId -> + album.album.playlistId?.let { addPlaylistId -> + YouTube.addPlaylistToPlaylist(playlistId, addPlaylistId) + } + } + } + songs.map { it.id } + }, + onDismiss = { + showChoosePlaylistDialog = false + }, + ) + + if (showErrorPlaylistAddDialog) { + ListDialog( + onDismiss = { + showErrorPlaylistAddDialog = false + onDismiss() + }, + ) { + item { + ListItem( + title = stringResource(R.string.already_in_playlist), + thumbnailContent = { + Image( + painter = painterResource(R.drawable.close), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(ListThumbnailSize), + ) + }, + modifier = + Modifier + .clickable { showErrorPlaylistAddDialog = false }, + ) + } + + items(notAddedList) { song -> + SongListItem(song = song) + } + } + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false }, + ) { + items( + items = album.artists, + key = { it.id }, + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + modifier = Modifier.padding(8.dp), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = artist.thumbnailUrl, + contentDescription = null, + modifier = + Modifier + .size(ListThumbnailSize) + .clip(CircleShape), + ) + } + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + } + } + } + } + + AlbumListItem( + album = album, + showLikedIcon = false, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.query { + update(album.album.toggleLike()) + } + }, + ) { + Icon( + painter = painterResource(if (album.album.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (album.album.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null, + ) + } + }, + ) + + HorizontalDivider() + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next, + ) { + onDismiss() + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + onDismiss() + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + DownloadGridMenu( + state = downloadState, + onDownload = { + songs.forEach { song -> + val downloadRequest = + DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + onRemoveDownload = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + } + }, + ) + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist, + ) { + if (album.artists.size == 1) { + navController.navigate("artist/${album.artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + GridMenuItem( + icon = { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation), + ) + }, + title = R.string.refetch, + ) { + refetchIconDegree -= 360 + scope.launch(Dispatchers.IO) { + YouTube.album(album.id).onSuccess { + database.transaction { + update(album.album, it, album.artists) + } + } + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share, + ) { + onDismiss() + val intent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/browse/${album.album.id}" + ) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/ArtistMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/ArtistMenu.kt new file mode 100644 index 00000000..ff0b7199 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/ArtistMenu.kt @@ -0,0 +1,141 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.content.Intent +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ArtistSongSortType +import com.darkxvenom.airbeats.db.entities.Artist +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.ui.component.ArtistListItem +import com.darkxvenom.airbeats.ui.component.GridMenu +import com.darkxvenom.airbeats.ui.component.GridMenuItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun ArtistMenu( + originalArtist: Artist, + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val artistState = database.artist(originalArtist.id).collectAsState(initial = originalArtist) + val artist = artistState.value ?: originalArtist + + ArtistListItem( + artist = artist, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.transaction { + update(artist.artist.toggleLike()) + } + }, + ) { + Icon( + painter = painterResource(if (artist.artist.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (artist.artist.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null, + ) + } + }, + ) + + HorizontalDivider() + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + if (artist.songCount > 0) { + GridMenuItem( + icon = R.drawable.play, + title = R.string.play, + ) { + coroutineScope.launch { + val songs = + withContext(Dispatchers.IO) { + database + .artistSongs(artist.id, ArtistSongSortType.CREATE_DATE, true) + .first() + .map { it.toMediaItem() } + } + playerConnection.playQueue( + ListQueue( + title = artist.artist.name, + items = songs, + ), + ) + } + onDismiss() + } + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle, + ) { + coroutineScope.launch { + val songs = + withContext(Dispatchers.IO) { + database + .artistSongs(artist.id, ArtistSongSortType.CREATE_DATE, true) + .first() + .map { it.toMediaItem() } + .shuffled() + } + playerConnection.playQueue( + ListQueue( + title = artist.artist.name, + items = songs, + ), + ) + } + onDismiss() + } + } + if (artist.artist.isYouTubeArtist) { + GridMenuItem( + icon = R.drawable.share, + title = R.string.share, + ) { + onDismiss() + val intent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/channel/${artist.id}" + ) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/ImportPlaylistDialog.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/ImportPlaylistDialog.kt new file mode 100644 index 00000000..0c90c6f0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/ImportPlaylistDialog.kt @@ -0,0 +1,63 @@ +package com.darkxvenom.airbeats.ui.menu + +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.ui.component.TextFieldDialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +@Composable +fun ImportPlaylistDialog( + isVisible: Boolean, + onGetSong: suspend () -> List, // list of song ids. Songs should be inserted to database in this function. + playlistTitle: String, + onDismiss: () -> Unit, +) { + val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + + val textFieldValue by remember { mutableStateOf(TextFieldValue(text = playlistTitle)) } + var songIds by remember { + mutableStateOf?>(null) // list is not saveable + } + + if (isVisible) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.add), contentDescription = null) }, + title = { Text(text = stringResource(R.string.import_playlist)) }, + initialTextFieldValue = textFieldValue, + autoFocus = false, + onDismiss = onDismiss, + onDone = { finalName -> + val newPlaylist = PlaylistEntity( + name = finalName + ) + database.query { insert(newPlaylist) } + + coroutineScope.launch(Dispatchers.IO) { + val playlist = database.playlist(newPlaylist.id).firstOrNull() + + if (playlist != null) { + songIds = onGetSong() + database.addSongToPlaylist(playlist, songIds!!) + } + + onDismiss() + } + } + ) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/LyricsMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/LyricsMenu.kt new file mode 100644 index 00000000..ac0b5913 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/LyricsMenu.kt @@ -0,0 +1,338 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.app.SearchManager +import android.content.Intent +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.entities.LyricsEntity +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.ui.component.DefaultDialog +import com.darkxvenom.airbeats.ui.component.GridMenu +import com.darkxvenom.airbeats.ui.component.GridMenuItem +import com.darkxvenom.airbeats.ui.component.ListDialog +import com.darkxvenom.airbeats.ui.component.TextFieldDialog +import com.darkxvenom.airbeats.viewmodels.LyricsMenuViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LyricsMenu( + lyricsProvider: () -> LyricsEntity?, + mediaMetadataProvider: () -> MediaMetadata, + onDismiss: () -> Unit, + onLyricsUpdated: () -> Unit = {}, // NUEVO: Callback para notificar actualización + viewModel: LyricsMenuViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + + var showEditDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + onDismiss = { showEditDialog = false }, + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = mediaMetadataProvider().title) }, + initialTextFieldValue = TextFieldValue(lyricsProvider()?.lyrics.orEmpty()), + singleLine = false, + onDone = { + database.query { + upsert( + LyricsEntity( + id = mediaMetadataProvider().id, + lyrics = it, + ), + ) + } + // NUEVO: Notificar que las letras fueron actualizadas + onLyricsUpdated() + onDismiss() + }, + ) + } + + var showSearchDialog by rememberSaveable { + mutableStateOf(false) + } + var showSearchResultDialog by rememberSaveable { + mutableStateOf(false) + } + + val searchMediaMetadata = + remember(showSearchDialog) { + mediaMetadataProvider() + } + val (titleField, onTitleFieldChange) = + rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = mediaMetadataProvider().title, + ), + ) + } + val (artistField, onArtistFieldChange) = + rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = mediaMetadataProvider().artists.joinToString { it.name }, + ), + ) + } + + if (showSearchDialog) { + DefaultDialog( + modifier = Modifier.verticalScroll(rememberScrollState()), + onDismiss = { showSearchDialog = false }, + icon = { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + }, + title = { Text(stringResource(R.string.search_lyrics)) }, + buttons = { + TextButton( + onClick = { showSearchDialog = false }, + ) { + Text(stringResource(android.R.string.cancel)) + } + + Spacer(Modifier.width(8.dp)) + + TextButton( + onClick = { + showSearchDialog = false + onDismiss() + try { + context.startActivity( + Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra( + SearchManager.QUERY, + "${artistField.text} ${titleField.text} lyrics" + ) + }, + ) + } catch (_: Exception) { + } + }, + ) { + Text(stringResource(R.string.search_online)) + } + + Spacer(Modifier.width(8.dp)) + + TextButton( + onClick = { + viewModel.search( + searchMediaMetadata.id, + titleField.text, + artistField.text, + searchMediaMetadata.duration + ) + showSearchResultDialog = true + }, + ) { + Text(stringResource(android.R.string.ok)) + } + }, + ) { + OutlinedTextField( + value = titleField, + onValueChange = onTitleFieldChange, + singleLine = true, + label = { Text(stringResource(R.string.song_title)) }, + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = artistField, + onValueChange = onArtistFieldChange, + singleLine = true, + label = { Text(stringResource(R.string.song_artists)) }, + ) + } + } + + if (showSearchResultDialog) { + val results by viewModel.results.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + var expandedItemIndex by rememberSaveable { + mutableStateOf(-1) + } + + ListDialog( + onDismiss = { showSearchResultDialog = false }, + ) { + itemsIndexed(results) { index, result -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + viewModel.cancelSearch() + database.query { + upsert( + LyricsEntity( + id = searchMediaMetadata.id, + lyrics = result.lyrics, + ), + ) + } + // NUEVO: Notificar actualización antes de cerrar + onLyricsUpdated() + showSearchResultDialog = false + onDismiss() + } + .padding(12.dp) + .animateContentSize(), + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = result.lyrics, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (index == expandedItemIndex) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = result.providerName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + ) + if (result.lyrics.startsWith("[")) { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = + Modifier + .padding(start = 4.dp) + .size(18.dp), + ) + } + } + } + + IconButton( + onClick = { + expandedItemIndex = if (expandedItemIndex == index) -1 else index + }, + ) { + Icon( + painter = painterResource(if (index == expandedItemIndex) R.drawable.expand_less else R.drawable.expand_more), + contentDescription = null, + ) + } + } + } + + if (isLoading) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + CircularProgressIndicator() + } + } + } + + if (!isLoading && results.isEmpty()) { + item { + Text( + text = context.getString(R.string.lyrics_not_found), + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth(), + ) + } + } + } + } + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.edit, + title = R.string.edit, + ) { + showEditDialog = true + } + GridMenuItem( + icon = R.drawable.cached, + title = R.string.refetch, + ) { + viewModel.refetchLyrics(mediaMetadataProvider(), lyricsProvider()) + // NUEVO: Notificar actualización cuando se recargan las letras + onLyricsUpdated() + onDismiss() + } + GridMenuItem( + icon = R.drawable.search, + title = R.string.search, + ) { + showSearchDialog = true + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/OnlinePlaylistAdder.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/OnlinePlaylistAdder.kt new file mode 100644 index 00000000..6986bb54 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/OnlinePlaylistAdder.kt @@ -0,0 +1,328 @@ +package com.darkxvenom.airbeats.ui.menu + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +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.text.style.TextAlign +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.models.ItemsPage +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.ui.component.CreatePlaylistDialog +import com.darkxvenom.airbeats.ui.component.DefaultDialog +import com.darkxvenom.airbeats.ui.component.ListDialog +import com.darkxvenom.airbeats.ui.component.ListItem +import com.darkxvenom.airbeats.ui.component.PlaylistListItem +import com.darkxvenom.airbeats.utils.reportException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +@Composable +fun OnlinePlaylistAdder( + isVisible: Boolean, + allowSyncing: Boolean = true, + initialTextFieldValue: String? = null, + songs: SnapshotStateList, // list of song ids. Songs should be inserted to database in this function. + onDismiss: () -> Unit, + onProgressStart: (Boolean) -> Unit, + onPercentageChange: (Int) -> Unit +) { + val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + val viewStateMap = remember { mutableStateMapOf() } + var playlists by remember { + mutableStateOf(emptyList()) + } + + + var showCreatePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var showDuplicateDialog by remember { + mutableStateOf(false) + } + var selectedPlaylist by remember { + mutableStateOf(null) + } + val songIds by remember { + mutableStateOf?>(null) // list is not saveable + } + val duplicates by remember { + mutableStateOf(emptyList()) + } + + + LaunchedEffect(Unit) { + database.editablePlaylistsByCreateDateAsc().collect { + playlists = it.asReversed() + } + } + + if (isVisible) { + ListDialog( + onDismiss = onDismiss + ) { + item { + ListItem( + title = stringResource(R.string.create_playlist), + thumbnailContent = { + Image( + painter = painterResource(id = R.drawable.playlist_add), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(ListThumbnailSize) + ) + }, + modifier = Modifier.clickable { + showCreatePlaylistDialog = true + } + ) + } + + items(playlists) { playlist -> + PlaylistListItem( + playlist = playlist, + modifier = Modifier.clickable { + selectedPlaylist = playlist + coroutineScope.launch(Dispatchers.IO) { + onDismiss() + val songsTot = songs.count().toDouble() + var songsIdx = 0.toDouble() + onProgressStart(true) + songs.reversed().forEach{ + song -> + var allArtists = "" + song.artists.forEach { + artist -> + allArtists += " ${URLDecoder.decode(artist.name, StandardCharsets.UTF_8.toString())}" + } + val query = "${song.title} - $allArtists" + + coroutineScope.launch { + try { + YouTube.search(query, YouTube.SearchFilter.FILTER_SONG) + .onSuccess { result -> + viewStateMap[YouTube.SearchFilter.FILTER_SONG.value] = + ItemsPage(result.items.distinctBy { it.id }, result.continuation) + val itemsPage = viewStateMap.entries.first().value!! + val firstSong = itemsPage.items[0] as SongItem + val firstSongMedia = firstSong.toMediaMetadata() + val ids = List(1) {firstSong.id} + withContext(Dispatchers.IO) { + try { + database.insert(firstSongMedia) + } catch (e: Exception) { + Timber.tag("Exception inserting song in database:") + .e(e.toString()) + } + database.addSongToPlaylist(playlist, ids) + } + viewStateMap.clear() + songsIdx += 1 + } + .onFailure { + reportException(it) + songsIdx += 1 + } + + if (songsIdx.toInt() == songsTot.toInt() - 1) { + onProgressStart(false) + } + onPercentageChange(((songsIdx / songsTot) * 100).toInt()) + + } catch (e: Exception){ + Timber.tag("ERROR").v(e.toString()) + } + + } + + } + + } + } + ) + } + + item { + ListItem( + modifier = Modifier.clickable { + coroutineScope.launch(Dispatchers.IO) { + onDismiss() + val songsTot = songs.count().toDouble() + var songsIdx = 0.toDouble() + onProgressStart(true) + songs.reversed().forEach{ + song -> + var allArtists = "" + song.artists.forEach { + artist -> + allArtists += " ${URLDecoder.decode(artist.name, StandardCharsets.UTF_8.toString())}" + } + val query = "${song.title} - $allArtists" + + coroutineScope.launch { + try { + YouTube.search(query, YouTube.SearchFilter.FILTER_SONG) + .onSuccess { result -> + viewStateMap[YouTube.SearchFilter.FILTER_SONG.value] = + ItemsPage(result.items.distinctBy { it.id }, result.continuation) + val itemsPage = viewStateMap.entries.first().value!! + val firstSong = itemsPage.items[0] as SongItem + val firstSongMedia = firstSong.toMediaMetadata() + val firstSongEnt = firstSong.toMediaMetadata().toSongEntity() + withContext(Dispatchers.IO) { + try { + database.insert(firstSongMedia) + database.query { + update(firstSongEnt.toggleLike()) + } + } catch (e: Exception) { + Timber.tag("Exception inserting song in database:") + .e(e.toString()) + } + } + viewStateMap.clear() + songsIdx += 1 + } + .onFailure { + reportException(it) + songsIdx += 1 + } + + if (songsIdx.toInt() == songsTot.toInt() - 1) { + onProgressStart(false) + } + onPercentageChange(((songsIdx / songsTot) * 100).toInt()) + + } catch (e: Exception){ + Timber.tag("ERROR").v(e.toString()) + } + + } + + } + + } + }, + title = stringResource(R.string.liked_songs), + thumbnailContent = { + Image( + painter = painterResource(id = R.drawable.favorite), // The XML image + contentDescription = null, + modifier = Modifier.size(40.dp), // Adjust size as needed + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) // Optional tinting + ) + }, + trailingContent = {} + ) + } + + item { + Text( + text = stringResource(R.string.playlist_add_local_to_synced_note), + fontSize = TextUnit(12F, TextUnitType.Sp), + modifier = Modifier.padding(horizontal = 20.dp) + ) + } + } + } + + if (showCreatePlaylistDialog) { + CreatePlaylistDialog( + onDismiss = { showCreatePlaylistDialog = false }, + initialTextFieldValue = initialTextFieldValue, + allowSyncing = allowSyncing + ) + } + + // duplicate songs warning + if (showDuplicateDialog) { + DefaultDialog( + title = { Text(stringResource(R.string.duplicates)) }, + buttons = { + TextButton( + onClick = { + showDuplicateDialog = false + onDismiss() + database.transaction { + addSongToPlaylist( + selectedPlaylist!!, + songIds!!.filter { + !duplicates.contains(it) + } + ) + } + } + ) { + Text(stringResource(R.string.skip_duplicates)) + } + + TextButton( + onClick = { + showDuplicateDialog = false + onDismiss() + database.transaction { + addSongToPlaylist(selectedPlaylist!!, songIds!!) + } + } + ) { + Text(stringResource(R.string.add_anyway)) + } + + TextButton( + onClick = { + showDuplicateDialog = false + } + ) { + Text(stringResource(android.R.string.cancel)) + } + }, + onDismiss = { + showDuplicateDialog = false + } + ) { + Text( + text = if (duplicates.size == 1) { + stringResource(R.string.duplicates_description_single) + } else { + stringResource(R.string.duplicates_description_multiple, duplicates.size) + }, + textAlign = TextAlign.Start, + modifier = Modifier.align(Alignment.Start) + ) + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlayerMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlayerMenu.kt new file mode 100644 index 00000000..5207ad40 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlayerMenu.kt @@ -0,0 +1,624 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.content.Intent +import android.media.audiofx.AudioEffect +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +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.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +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.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri +import androidx.media3.common.PlaybackParameters +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.BottomSheetState +import com.darkxvenom.airbeats.ui.component.DownloadGridMenu +import com.darkxvenom.airbeats.ui.component.GridMenu +import com.darkxvenom.airbeats.ui.component.GridMenuItem +import com.darkxvenom.airbeats.ui.component.ListDialog +import com.darkxvenom.airbeats.ui.component.ListItem +import com.darkxvenom.airbeats.utils.joinByBullet +import com.darkxvenom.airbeats.utils.makeTimeString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import kotlin.math.log2 +import kotlin.math.pow +import kotlin.math.round + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayerMenu( + mediaMetadata: MediaMetadata?, + navController: NavController, + playerBottomSheetState: BottomSheetState, + isQueueTrigger: Boolean? = false, + onShowDetailsDialog: () -> Unit, + onDismiss: () -> Unit, +) { + mediaMetadata ?: return + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val playerVolume = playerConnection.service.playerVolume.collectAsState() + val activityResultLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } + val librarySong by database.song(mediaMetadata.id).collectAsState(initial = null) + val coroutineScope = rememberCoroutineScope() + + val bottomSheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + val download by LocalDownloadUtil.current.getDownload(mediaMetadata.id) + .collectAsState(initial = null) + + val artists = + remember(mediaMetadata.artists) { + mediaMetadata.artists.filter { it.id != null } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var showErrorPlaylistAddDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + database.transaction { + insert(mediaMetadata) + } + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { YouTube.addToPlaylist(it, mediaMetadata.id) } + } + listOf(mediaMetadata.id) + }, + onDismiss = { + showChoosePlaylistDialog = false + } + ) + + if (showErrorPlaylistAddDialog) { + ListDialog( + onDismiss = { + showErrorPlaylistAddDialog = false + onDismiss() + }, + ) { + item { + ListItem( + title = stringResource(R.string.already_in_playlist), + thumbnailContent = { + Image( + painter = painterResource(R.drawable.close), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(ListThumbnailSize), + ) + }, + modifier = + Modifier + .clickable { showErrorPlaylistAddDialog = false }, + ) + } + + item { + ListItem( + title = mediaMetadata.title, + thumbnailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(ListThumbnailSize), + ) { + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + } + }, + subtitle = + joinByBullet( + mediaMetadata.artists.joinToString { it.name }, + makeTimeString(mediaMetadata.duration * 1000L), + ), + ) + } + } + } + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false }, + ) { + items(artists) { artist -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = + Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + playerBottomSheetState.collapseSoft() + onDismiss() + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + + var showPitchTempoDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showPitchTempoDialog) { + TempoPitchDialog( + onDismiss = { showPitchTempoDialog = false }, + ) + } + + if (isQueueTrigger != true) { + // State to track if audio is muted + var isMuted by remember { mutableStateOf(false) } + + // Store the volume before muting to restore later + var previousVolume by remember { mutableFloatStateOf(playerVolume.value) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = bottomSheetState, + dragHandle = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(vertical = 8.dp) + ) { + // Drag handle + Box( + modifier = Modifier + .width(32.dp) + .height(4.dp) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(2.dp) + ) + ) + } + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(100.dp) + ) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Icon( + painter = painterResource( + id = if (isMuted) R.drawable.volume_off + else R.drawable.volume_up + ), + contentDescription = stringResource( + if (isMuted) R.string.unmute + else R.string.mute + ), + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable { + isMuted = !isMuted + if (isMuted) { + previousVolume = playerVolume.value + playerConnection.service.playerVolume.value = 0f + } else { + playerConnection.service.playerVolume.value = previousVolume + } + } + .padding(4.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Slider( + value = if (isMuted) 0f else playerVolume.value, + onValueChange = { newVolume -> + if (!isMuted) { + playerConnection.service.playerVolume.value = newVolume + previousVolume = newVolume + } + }, + valueRange = 0f..1f, + modifier = Modifier.weight(1f), + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) + + Text( + text = if (isMuted) "0%" else "${(playerVolume.value * 100).toInt()}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(40.dp), + textAlign = TextAlign.End + ) + } + + // Divisor + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + + // Grid de opciones + GridMenu( + contentPadding = PaddingValues( + bottom = WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + 16.dp + ), + ) { + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio, + ) { + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = mediaMetadata.id), + mediaMetadata + ) + ) + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + DownloadGridMenu( + state = download?.state, + onDownload = { + database.transaction { + insert(mediaMetadata) + } + val downloadRequest = + DownloadRequest + .Builder(mediaMetadata.id, mediaMetadata.id.toUri()) + .setCustomCacheKey(mediaMetadata.id) + .setData(mediaMetadata.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + mediaMetadata.id, + false, + ) + }, + ) + if (librarySong?.song?.inLibrary != null) { + GridMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library, + ) { + database.query { + inLibrary(mediaMetadata.id, null) + } + } + } else { + GridMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library, + ) { + database.transaction { + insert(mediaMetadata) + inLibrary(mediaMetadata.id, LocalDateTime.now()) + } + } + } + if (artists.isNotEmpty()) { + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist, + ) { + if (mediaMetadata.artists.size == 1) { + navController.navigate("artist/${mediaMetadata.artists[0].id}") + playerBottomSheetState.collapseSoft() + onDismiss() + } else { + showSelectArtistDialog = true + } + } + } + if (mediaMetadata.album != null) { + GridMenuItem( + icon = R.drawable.album, + title = R.string.view_album, + ) { + navController.navigate("album/${mediaMetadata.album.id}") + playerBottomSheetState.collapseSoft() + onDismiss() + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share, + ) { + val intent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "https://music.youtube.com/watch?v=${mediaMetadata.id}" + ) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + GridMenuItem( + icon = R.drawable.info, + title = R.string.details, + ) { + onShowDetailsDialog() + onDismiss() + } + GridMenuItem( + icon = R.drawable.equalizer, + title = R.string.equalizer, + ) { + val intent = + Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { + putExtra( + AudioEffect.EXTRA_AUDIO_SESSION, + playerConnection.player.audioSessionId, + ) + putExtra( + AudioEffect.EXTRA_PACKAGE_NAME, + context.packageName + ) + putExtra( + AudioEffect.EXTRA_CONTENT_TYPE, + AudioEffect.CONTENT_TYPE_MUSIC + ) + } + if (intent.resolveActivity(context.packageManager) != null) { + activityResultLauncher.launch(intent) + } + onDismiss() + } + GridMenuItem( + icon = R.drawable.tune, + title = R.string.advanced, + ) { + showPitchTempoDialog = true + } + } + } + } + } +} + +@Composable +fun TempoPitchDialog(onDismiss: () -> Unit) { + val playerConnection = LocalPlayerConnection.current ?: return + var tempo by remember { + mutableFloatStateOf(playerConnection.player.playbackParameters.speed) + } + var transposeValue by remember { + mutableIntStateOf(round(12 * log2(playerConnection.player.playbackParameters.pitch)).toInt()) + } + val updatePlaybackParameters = { + playerConnection.player.playbackParameters = + PlaybackParameters(tempo, 2f.pow(transposeValue.toFloat() / 12)) + } + + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = onDismiss, + title = { + Text(stringResource(R.string.tempo_and_pitch)) + }, + dismissButton = { + TextButton( + onClick = { + tempo = 1f + transposeValue = 0 + updatePlaybackParameters() + }, + ) { + Text(stringResource(R.string.reset)) + } + }, + confirmButton = { + TextButton( + onClick = onDismiss, + ) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { + Column { + ValueAdjuster( + icon = R.drawable.speed, + currentValue = tempo, + values = (0..35).map { round((0.25f + it * 0.05f) * 100) / 100 }, + onValueUpdate = { + tempo = it + updatePlaybackParameters() + }, + valueText = { "x$it" }, + modifier = Modifier.padding(bottom = 12.dp), + ) + ValueAdjuster( + icon = R.drawable.discover_tune, + currentValue = transposeValue, + values = (-12..12).toList(), + onValueUpdate = { + transposeValue = it + updatePlaybackParameters() + }, + valueText = { "${if (it > 0) "+" else ""}$it" }, + ) + } + }, + ) +} + +@Composable +fun ValueAdjuster( + @DrawableRes icon: Int, + currentValue: T, + values: List, + onValueUpdate: (T) -> Unit, + valueText: (T) -> String, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier.size(28.dp), + ) + + IconButton( + enabled = currentValue != values.first(), + onClick = { + onValueUpdate(values[values.indexOf(currentValue) - 1]) + }, + ) { + Icon( + painter = painterResource(R.drawable.remove), + contentDescription = null, + ) + } + + Text( + text = valueText(currentValue), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.width(80.dp), + ) + + IconButton( + enabled = currentValue != values.last(), + onClick = { + onValueUpdate(values[values.indexOf(currentValue) + 1]) + }, + ) { + Icon( + painter = painterResource(R.drawable.add), + contentDescription = null, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlaylistMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlaylistMenu.kt new file mode 100644 index 00000000..07cabdb1 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/PlaylistMenu.kt @@ -0,0 +1,416 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.content.Intent +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.PlaylistSong +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.DefaultDialog +import com.darkxvenom.airbeats.ui.component.DownloadGridMenu +import com.darkxvenom.airbeats.ui.component.GridMenu +import com.darkxvenom.airbeats.ui.component.GridMenuItem +import com.darkxvenom.airbeats.ui.component.PlaylistListItem +import com.darkxvenom.airbeats.ui.component.TextFieldDialog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.LocalDateTime + + +@Composable +fun PlaylistMenu( + playlist: Playlist, + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, + autoPlaylist: Boolean? = false, + downloadPlaylist: Boolean? = false, + songList: List? = emptyList(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + val dbPlaylist by database.playlist(playlist.id).collectAsState(initial = playlist) + var songs by remember { + mutableStateOf(emptyList()) + } + + LaunchedEffect(Unit) { + if (autoPlaylist == false) { + database.playlistSongs(playlist.id).collect { + songs = it.map(PlaylistSong::song) + } + } else { + if (songList != null) { + songs = songList + } + } + } + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + val editable: Boolean = playlist.playlist.isEditable == true + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == Download.STATE_COMPLETED }) { + Download.STATE_COMPLETED + } else if (songs.all { + downloads[it.id]?.state == Download.STATE_QUEUED || + downloads[it.id]?.state == Download.STATE_DOWNLOADING || + downloads[it.id]?.state == Download.STATE_COMPLETED + } + ) { + Download.STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + var showEditDialog by remember { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.edit_playlist)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = + TextFieldValue( + playlist.playlist.name, + TextRange(playlist.playlist.name.length), + ), + onDone = { name -> + onDismiss() + database.query { + update( + playlist.playlist.copy( + name = name, + lastUpdateTime = LocalDateTime.now() + ) + ) + } + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { YouTube.renamePlaylist(it, name) } + } + }, + ) + } + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource( + R.string.remove_download_playlist_confirm, + playlist.playlist.name + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp), + ) + }, + buttons = { + TextButton( + onClick = { + showRemoveDownloadDialog = false + }, + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + } + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) + } + + var showDeletePlaylistDialog by remember { + mutableStateOf(false) + } + + if (showDeletePlaylistDialog) { + DefaultDialog( + onDismiss = { showDeletePlaylistDialog = false }, + content = { + Text( + text = stringResource(R.string.delete_playlist_confirm, playlist.playlist.name), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { + showDeletePlaylistDialog = false + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showDeletePlaylistDialog = false + onDismiss() + database.transaction { + // First toggle the like using the same logic as the like button + if (playlist.playlist.bookmarkedAt != null) { + // Using the same toggleLike() method that's used in the like button + update(playlist.playlist.toggleLike()) + } + // Then delete the playlist + delete(playlist.playlist) + } + + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { YouTube.deletePlaylist(it) } + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { + coroutineScope.launch(Dispatchers.IO) { + // add songs to playlist and push to ytm + playlist.playlist.browseId?.let { playlistId -> + YouTube.addPlaylistToPlaylist(playlistId, playlist.id) + } + } + songs.map { it.id } + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + PlaylistListItem( + playlist = playlist, + trailingContent = { + if (playlist.playlist.isEditable != true) { + IconButton( + onClick = { + database.query { + dbPlaylist?.playlist?.toggleLike()?.let { update(it) } + } + } + ) { + Icon( + painter = painterResource(if (dbPlaylist?.playlist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (dbPlaylist?.playlist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + }, + ) + + HorizontalDivider() + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.play, + title = R.string.play, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.map { it.toMediaItem() }, + ), + ) + } + + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.shuffled().map { it.toMediaItem() }, + ), + ) + } + + playlist.playlist.browseId?.let { browseId -> + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio + ) { + coroutineScope.launch(Dispatchers.IO) { + YouTube.playlist(browseId).getOrNull()?.playlist?.let { playlistItem -> + playlistItem.radioEndpoint?.let { radioEndpoint -> + withContext(Dispatchers.Main) { + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + } + } + } + } + onDismiss() + } + } + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next + ) { + coroutineScope.launch { + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + onDismiss() + } + + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + onDismiss() + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + + if (editable && autoPlaylist != true) { + GridMenuItem( + icon = R.drawable.edit, + title = R.string.edit + ) { + showEditDialog = true + } + } + + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + + if (downloadPlaylist != true) { + DownloadGridMenu( + state = downloadState, + onDownload = { + songs.forEach { song -> + val downloadRequest = + DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + onRemoveDownload = { + showRemoveDownloadDialog = true + }, + ) + } + + if (autoPlaylist != true) { + GridMenuItem( + icon = R.drawable.delete, + title = R.string.delete, + ) { + showDeletePlaylistDialog = true + } + } + + playlist.playlist.shareLink?.let { shareLink -> + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/SelectionSongsMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/SelectionSongsMenu.kt new file mode 100644 index 00000000..377f250d --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/SelectionSongsMenu.kt @@ -0,0 +1,527 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.entities.PlaylistSongMap +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.ui.component.DefaultDialog +import com.darkxvenom.airbeats.ui.component.DownloadGridMenu +import com.darkxvenom.airbeats.ui.component.GridMenu +import com.darkxvenom.airbeats.ui.component.GridMenuItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@SuppressLint("MutableCollectionMutableState") +@Composable +fun SelectionSongMenu( + songSelection: List, + onDismiss: () -> Unit, + clearAction: () -> Unit, + songPosition: List? = emptyList(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val coroutineScope = rememberCoroutineScope() + val playerConnection = LocalPlayerConnection.current ?: return + + val allInLibrary by remember { + mutableStateOf( + songSelection.all { + it.song.inLibrary != null + }, + ) + } + + val allLiked by remember(songSelection) { + mutableStateOf( + songSelection.isNotEmpty() && songSelection.all { + it.song.liked + }, + ) + } + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songSelection) { + if (songSelection.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songSelection.all { downloads[it.id]?.state == Download.STATE_COMPLETED }) { + Download.STATE_COMPLETED + } else if (songSelection.all { + downloads[it.id]?.state == Download.STATE_QUEUED || + downloads[it.id]?.state == Download.STATE_DOWNLOADING || + downloads[it.id]?.state == Download.STATE_COMPLETED + } + ) { + Download.STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + val notAddedList by remember { + mutableStateOf(mutableListOf()) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + coroutineScope.launch(Dispatchers.IO) { + songSelection.forEach { song -> + playlist.playlist.browseId?.let { browseId -> + YouTube.addToPlaylist(browseId, song.id) + } + } + } + songSelection.map { it.id } + }, + onDismiss = { + showChoosePlaylistDialog = false + }, + ) + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource(R.string.remove_download_playlist_confirm, "selection"), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp), + ) + }, + buttons = { + TextButton( + onClick = { + showRemoveDownloadDialog = false + }, + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + songSelection.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false, + ) + } + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) + } + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.play, + title = R.string.play, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = "Selection", + items = songSelection.map { it.toMediaItem() }, + ), + ) + clearAction() + } + + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = "Selection", + items = songSelection.shuffled().map { it.toMediaItem() }, + ), + ) + clearAction() + } + + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + onDismiss() + playerConnection.addToQueue(songSelection.map { it.toMediaItem() }) + clearAction() + } + + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + + if (allInLibrary) { + GridMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library, + ) { + database.query { + songSelection.forEach { song -> + inLibrary(song.id, null) + } + } + } + } else { + GridMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library, + ) { + database.transaction { + songSelection.forEach { song -> + insert(song.toMediaMetadata()) + inLibrary(song.id, LocalDateTime.now()) + } + } + } + } + + DownloadGridMenu( + state = downloadState, + onDownload = { + songSelection.forEach { song -> + val downloadRequest = + DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + onRemoveDownload = { + showRemoveDownloadDialog = true + }, + ) + + GridMenuItem( + icon = if (allLiked) R.drawable.favorite else R.drawable.favorite_border, + title = if (allLiked) R.string.dislike_all else R.string.like_all, + ) { + val allLiked = + songSelection.all { + it.song.liked + } + onDismiss() + database.query { + songSelection.forEach { song -> + if ((!allLiked && !song.song.liked) || allLiked) { + update(song.song.toggleLike()) + } + } + } + } + + if (songPosition?.size != 0) { + GridMenuItem( + icon = R.drawable.delete, + title = R.string.delete, + ) { + onDismiss() + var i = 0 + database.query { + songPosition?.forEach { cur -> + move(cur.playlistId, cur.position - i, Int.MAX_VALUE) + delete(cur.copy(position = Int.MAX_VALUE)) + i++ + } + } + clearAction() + } + } + } +} + +@SuppressLint("MutableCollectionMutableState") +@Composable +fun SelectionMediaMetadataMenu( + songSelection: List, + currentItems: List, + onDismiss: () -> Unit, + clearAction: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val coroutineScope = rememberCoroutineScope() + val playerConnection = LocalPlayerConnection.current ?: return + + val allLiked by remember(songSelection) { + mutableStateOf(songSelection.isNotEmpty() && songSelection.all { it.liked }) + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + val notAddedList by remember { + mutableStateOf(mutableListOf()) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + coroutineScope.launch(Dispatchers.IO) { + songSelection.forEach { song -> + playlist.playlist.browseId?.let { browseId -> + YouTube.addToPlaylist(browseId, song.id) + } + } + } + songSelection.map { it.id } + }, + onDismiss = { + showChoosePlaylistDialog = false + }, + ) + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songSelection) { + if (songSelection.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songSelection.all { downloads[it.id]?.state == Download.STATE_COMPLETED }) { + Download.STATE_COMPLETED + } else if (songSelection.all { + downloads[it.id]?.state == Download.STATE_QUEUED || + downloads[it.id]?.state == Download.STATE_DOWNLOADING || + downloads[it.id]?.state == Download.STATE_COMPLETED + } + ) { + Download.STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource(R.string.remove_download_playlist_confirm, "selection"), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp), + ) + }, + buttons = { + TextButton( + onClick = { + showRemoveDownloadDialog = false + }, + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + songSelection.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + } + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) + } + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + if (currentItems.isNotEmpty()) { + GridMenuItem( + icon = R.drawable.delete, + title = R.string.delete, + ) { + onDismiss() + var i = 0 + currentItems.forEach { cur -> + if (playerConnection.player.availableCommands.contains(Player.COMMAND_CHANGE_MEDIA_ITEMS)) { + playerConnection.player.removeMediaItem(cur.firstPeriodIndex - i++) + } + } + clearAction() + } + } + + GridMenuItem( + icon = R.drawable.play, + title = R.string.play, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = "Selection", + items = songSelection.map { it.toMediaItem() }, + ), + ) + clearAction() + } + + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle, + ) { + onDismiss() + playerConnection.playQueue( + ListQueue( + title = "Selection", + items = songSelection.shuffled().map { it.toMediaItem() }, + ), + ) + clearAction() + } + + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + onDismiss() + playerConnection.addToQueue(songSelection.map { it.toMediaItem() }) + clearAction() + } + + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + + GridMenuItem( + icon = if (allLiked) R.drawable.favorite else R.drawable.favorite_border, + title = R.string.like_all, + ) { + database.query { + if (allLiked) { + songSelection.forEach { song -> + update(song.toSongEntity().toggleLike()) + } + } else { + songSelection.filter { !it.liked }.forEach { song -> + update(song.toSongEntity().toggleLike()) + } + } + } + } + + DownloadGridMenu( + state = downloadState, + onDownload = { + songSelection.forEach { song -> + val downloadRequest = + DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + onRemoveDownload = { + showRemoveDownloadDialog = true + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/SongMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/SongMenu.kt new file mode 100644 index 00000000..39506a72 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/SongMenu.kt @@ -0,0 +1,653 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.content.Intent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.LocalSyncUtils +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.db.entities.Event +import com.darkxvenom.airbeats.db.entities.PlaylistSong +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.ListDialog +import com.darkxvenom.airbeats.ui.component.LocalBottomSheetPageState +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.TextFieldDialog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun SongMenu( + originalSong: Song, + event: Event? = null, + navController: NavController, + playlistSong: PlaylistSong? = null, + playlistBrowseId: String? = null, + onDismiss: () -> Unit, + isFromCache: Boolean = false, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val songState = database.song(originalSong.id).collectAsState(initial = originalSong) + val song = songState.value ?: originalSong + val download by LocalDownloadUtil.current.getDownload(originalSong.id) + .collectAsState(initial = null) + val coroutineScope = rememberCoroutineScope() + val syncUtils = LocalSyncUtils.current + val scope = rememberCoroutineScope() + var refetchIconDegree by remember { mutableFloatStateOf(0f) } + + + val rotationAnimation by animateFloatAsState( + targetValue = refetchIconDegree, + animationSpec = tween(durationMillis = 800), + label = "", + ) + + var showEditDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.edit_song)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue( + song.song.title, + TextRange(song.song.title.length) + ), + onDone = { title -> + onDismiss() + database.query { + update(song.song.copy(title = title)) + } + }, + ) + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var showErrorPlaylistAddDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { browseId -> + YouTube.addToPlaylist(browseId, song.id) + } + } + listOf(song.id) + }, + onDismiss = { + showChoosePlaylistDialog = false + }, + ) + + if (showErrorPlaylistAddDialog) { + ListDialog( + onDismiss = { + showErrorPlaylistAddDialog = false + onDismiss() + }, + ) { + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.already_in_playlist)) }, + leadingContent = { + Image( + painter = painterResource(R.drawable.close), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(ListThumbnailSize), + ) + }, + modifier = Modifier.clickable { showErrorPlaylistAddDialog = false }, + ) + } + + items(listOf(song)) { song -> + SongListItem(song = song) + } + } + } + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false }, + ) { + items( + items = song.artists, + key = { it.id }, + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + modifier = Modifier.padding(8.dp), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = artist.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(CircleShape), + ) + } + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + } + } + } + } + + SongListItem( + song = song, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.query { + update(song.song.toggleLike()) + } + }, + ) { + Icon( + painter = painterResource(if (song.song.liked) R.drawable.favorite else R.drawable.favorite_border), + tint = if (song.song.liked) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null, + ) + } + }, + ) + + HorizontalDivider() + + Spacer(modifier = Modifier.height(12.dp)) + + val bottomSheetPageState = LocalBottomSheetPageState.current + + // Row for "Play next", "Add to playlist", and "Share" buttons with grid-like background + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp), + ) { + // Play next button + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + playerConnection.playNext(song.toMediaItem()) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_play), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.play_next), + style = MaterialTheme.typography.labelMedium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + + // Add to playlist button + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + showChoosePlaylistDialog = true + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.playlist_add), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.add_to_playlist), + style = MaterialTheme.typography.labelMedium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + + // Share button + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onDismiss() + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${song.id}") + } + context.startActivity(Intent.createChooser(intent, null)) + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + LazyColumn( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.start_radio)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + onDismiss() + playerConnection.playQueue(YouTubeQueue.radio(song.toMediaMetadata())) + } + ) + } + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.edit)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.edit), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + showEditDialog = true + } + ) + } + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.add_to_queue)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + onDismiss() + playerConnection.addToQueue(song.toMediaItem()) + } + ) + } + item { + ListItem( + headlineContent = { + Text( + text = stringResource( + if (song.song.inLibrary == null) R.string.add_to_library + else R.string.remove_from_library + ) + ) + }, + leadingContent = { + Icon( + painter = painterResource( + if (song.song.inLibrary == null) R.drawable.library_add + else R.drawable.library_add_check + ), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + database.query { + update(song.song.toggleLibrary()) + } + } + ) + } + if (event != null) { + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.remove_from_history)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.delete), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + onDismiss() + database.query { + delete(event) + } + } + ) + } + } + if (playlistSong != null) { + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.remove_from_playlist)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.delete), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + database.transaction { + coroutineScope.launch { + playlistBrowseId?.let { playlistId -> + if (playlistSong.map.setVideoId != null) { + YouTube.removeFromPlaylist( + playlistId, + playlistSong.map.songId, + playlistSong.map.setVideoId + ) + } + } + } + move( + playlistSong.map.playlistId, + playlistSong.map.position, + Int.MAX_VALUE + ) + delete(playlistSong.map.copy(position = Int.MAX_VALUE)) + } + onDismiss() + } + ) + } + } + item { + when (download?.state) { + Download.STATE_COMPLETED -> { + ListItem( + headlineContent = { + Text( + text = stringResource(R.string.remove_download), + color = MaterialTheme.colorScheme.error + ) + }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + } + ) + } + + Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { + ListItem( + headlineContent = { Text(text = stringResource(R.string.downloading)) }, + leadingContent = { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + }, + modifier = Modifier.clickable { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + } + ) + } + + else -> { + ListItem( + headlineContent = { Text(text = stringResource(R.string.download)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + val downloadRequest = + DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + ) + } + } + } + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.view_artist)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.artist), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + if (song.artists.size == 1) { + navController.navigate("artist/${song.artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + ) + } + if (song.song.albumId != null) { + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.view_album)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.album), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + onDismiss() + navController.navigate("album/${song.song.albumId}") + } + ) + } + } + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.refetch)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation), + ) + }, + modifier = Modifier.clickable { + refetchIconDegree -= 360 + scope.launch(Dispatchers.IO) { + YouTube.queue(listOf(song.id)).onSuccess { + val newSong = it.firstOrNull() + if (newSong != null) { + database.transaction { + update(song, newSong.toMediaMetadata()) + } + } + } + } + } + ) + } + item { + ListItem( + headlineContent = { Text(text = stringResource(R.string.details)) }, + leadingContent = { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + onDismiss() + bottomSheetPageState.show { + (song.id) + } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeAlbumMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeAlbumMenu.kt new file mode 100644 index 00000000..223c80a0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeAlbumMenu.kt @@ -0,0 +1,353 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.annotation.SuppressLint +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.YouTubeAlbumRadio +import com.darkxvenom.airbeats.ui.component.DownloadGridMenu +import com.darkxvenom.airbeats.ui.component.GridMenu +import com.darkxvenom.airbeats.ui.component.GridMenuItem +import com.darkxvenom.airbeats.ui.component.ListDialog +import com.darkxvenom.airbeats.ui.component.ListItem +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.YouTubeListItem +import com.darkxvenom.airbeats.utils.reportException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("MutableCollectionMutableState") +@Composable +fun YouTubeAlbumMenu( + albumItem: AlbumItem, + navController: NavController, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + val album by database.albumWithSongs(albumItem.id).collectAsState(initial = null) + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + database.album(albumItem.id).collect { album -> + if (album == null) { + YouTube + .album(albumItem.id) + .onSuccess { albumPage -> + database.transaction { + insert(albumPage) + } + }.onFailure { + reportException(it) + } + } + } + } + + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(album) { + val songs = album?.songs?.map { it.id } ?: return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) { + Download.STATE_COMPLETED + } else if (songs.all { + downloads[it]?.state == Download.STATE_QUEUED || + downloads[it]?.state == Download.STATE_DOWNLOADING || + downloads[it]?.state == Download.STATE_COMPLETED + } + ) { + Download.STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + var showErrorPlaylistAddDialog by rememberSaveable { + mutableStateOf(false) + } + + val notAddedList by remember { + mutableStateOf(mutableListOf()) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { playlistId -> + album?.album?.playlistId?.let { addPlaylistId -> + YouTube.addPlaylistToPlaylist(playlistId, addPlaylistId) + } + } + } + album?.songs?.map { it.id }.orEmpty() + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + if (showErrorPlaylistAddDialog) { + ListDialog( + onDismiss = { + showErrorPlaylistAddDialog = false + onDismiss() + }, + ) { + item { + ListItem( + title = stringResource(R.string.already_in_playlist), + thumbnailContent = { + Image( + painter = painterResource(R.drawable.close), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(ListThumbnailSize), + ) + }, + modifier = + Modifier + .clickable { showErrorPlaylistAddDialog = false }, + ) + } + + items(notAddedList) { song -> + SongListItem(song = song) + } + } + } + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false }, + ) { + items( + items = album?.artists.orEmpty(), + key = { it.id }, + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = + Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + showSelectArtistDialog = false + onDismiss() + navController.navigate("artist/${artist.id}") + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + + YouTubeListItem( + item = albumItem, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.query { + album?.album?.toggleLike()?.let(::update) + } + }, + ) { + Icon( + painter = painterResource(if (album?.album?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (album?.album?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null, + ) + } + }, + ) + + HorizontalDivider() + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio, + ) { + playerConnection.playQueue(YouTubeAlbumRadio(albumItem.playlistId)) + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next, + ) { + album + ?.songs + ?.map { it.toMediaItem() } + ?.let(playerConnection::playNext) + onDismiss() + } + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + album + ?.songs + ?.map { it.toMediaItem() } + ?.let(playerConnection::addToQueue) + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + DownloadGridMenu( + state = downloadState, + onDownload = { + album?.songs?.forEach { song -> + val downloadRequest = + DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + onRemoveDownload = { + album?.songs?.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + } + }, + ) + albumItem.artists?.let { artists -> + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist, + ) { + if (artists.size == 1) { + navController.navigate("artist/${artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share, + ) { + val intent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, albumItem.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeArtistMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeArtistMenu.kt new file mode 100644 index 00000000..54d7fdbe --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeArtistMenu.kt @@ -0,0 +1,227 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.darkxvenom.airbeats.innertube.models.ArtistItem +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.entities.ArtistEntity +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.YouTubeListItem + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun YouTubeArtistMenu( + artist: ArtistItem, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val libraryArtist by database.artist(artist.id).collectAsState(initial = null) + + YouTubeListItem( + item = artist, + trailingContent = {}, + ) + + HorizontalDivider() + + Spacer(modifier = Modifier.height(12.dp)) + + // Row for "Start Radio", "Shuffle", and "Share" buttons with grid-like background + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Start Radio button + artist.radioEndpoint?.let { watchEndpoint -> + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + playerConnection.playQueue(YouTubeQueue(watchEndpoint)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.start_radio), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + // Shuffle button + artist.shuffleEndpoint?.let { watchEndpoint -> + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + playerConnection.playQueue(YouTubeQueue(watchEndpoint)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.shuffle), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + // Share button + Column( + modifier = Modifier + .weight(1f) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + .clickable { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, artist.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(R.drawable.share), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(R.string.share), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .basicMarquee() + .padding(top = 4.dp), + ) + } + } + + LazyColumn( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + // Subscribe/Subscribed button + item { + ListItem( + headlineContent = { + Text( + text = if (libraryArtist?.artist?.bookmarkedAt != null) stringResource(R.string.subscribed) else stringResource( + R.string.subscribe + ) + ) + }, + leadingContent = { + Icon( + painter = painterResource( + if (libraryArtist?.artist?.bookmarkedAt != null) { + R.drawable.subscribed + } else { + R.drawable.subscribe + } + ), + contentDescription = null, + ) + }, + modifier = Modifier.clickable { + database.query { + val libraryArtist = libraryArtist + if (libraryArtist != null) { + update(libraryArtist.artist.toggleLike()) + } else { + insert( + ArtistEntity( + id = artist.id, + name = artist.title, + channelId = artist.channelId, + thumbnailUrl = artist.thumbnail, + ).toggleLike() + ) + } + } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubePlaylistMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubePlaylistMenu.kt new file mode 100644 index 00000000..7c21a11a --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubePlaylistMenu.kt @@ -0,0 +1,507 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.annotation.SuppressLint +import android.content.Intent +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +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.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +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.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.utils.completed +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.db.entities.PlaylistSongMap +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.DefaultDialog +import com.darkxvenom.airbeats.ui.component.DownloadGridMenu +import com.darkxvenom.airbeats.ui.component.GridMenu +import com.darkxvenom.airbeats.ui.component.GridMenuItem +import com.darkxvenom.airbeats.ui.component.ListDialog +import com.darkxvenom.airbeats.ui.component.ListItem +import com.darkxvenom.airbeats.ui.component.YouTubeListItem +import com.darkxvenom.airbeats.utils.joinByBullet +import com.darkxvenom.airbeats.utils.makeTimeString +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@SuppressLint("MutableCollectionMutableState") +@Composable +fun YouTubePlaylistMenu( + playlist: PlaylistItem, + songs: List = emptyList(), + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, + selectAction: () -> Unit = {}, + canSelect: Boolean = false, + onRefresh: () -> Unit = {}, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val downloadUtil = LocalDownloadUtil.current + val playerConnection = LocalPlayerConnection.current ?: return + val dbPlaylist by database.playlistByBrowseId(playlist.id).collectAsState(initial = null) + + var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) } + var showImportPlaylistDialog by rememberSaveable { mutableStateOf(false) } + var showRenameDialog by rememberSaveable { mutableStateOf(false) } + var renameText by rememberSaveable { mutableStateOf(playlist.title) } + + var showErrorPlaylistAddDialog by rememberSaveable { + mutableStateOf(false) + } + + val notAddedList by remember { + mutableStateOf(mutableListOf()) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { targetPlaylist -> + val allSongs = songs + .ifEmpty { + YouTube.playlist(targetPlaylist.id).completed().getOrNull()?.songs.orEmpty() + }.map { + it.toMediaMetadata() + } + database.transaction { + allSongs.forEach(::insert) + } + coroutineScope.launch(Dispatchers.IO) { + targetPlaylist.playlist.browseId?.let { playlistId -> + YouTube.addPlaylistToPlaylist(playlistId, targetPlaylist.id) + } + } + allSongs.map { it.id } + }, + onDismiss = { showChoosePlaylistDialog = false }, + ) + + YouTubeListItem( + item = playlist, + trailingContent = { + if (playlist.id != "LM" && !playlist.isEditable) { + IconButton( + onClick = { + if (dbPlaylist?.playlist == null) { + database.transaction { + val playlistEntity = PlaylistEntity( + name = playlist.title, + browseId = playlist.id, + isEditable = false, + remoteSongCount = playlist.songCountText?.let { + Regex("""\d+""").find( + it + )?.value?.toIntOrNull() + }, + playEndpointParams = playlist.playEndpoint?.params, + shuffleEndpointParams = playlist.shuffleEndpoint?.params, + radioEndpointParams = playlist.radioEndpoint?.params + ).toggleLike() + insert(playlistEntity) + coroutineScope.launch(Dispatchers.IO) { + songs.ifEmpty { + YouTube.playlist(playlist.id).completed() + .getOrNull()?.songs.orEmpty() + }.map { it.toMediaMetadata() } + .onEach(::insert) + .mapIndexed { index, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlistEntity.id, + position = index + ) + } + .forEach(::insert) + } + } + } else { + database.transaction { + update(dbPlaylist!!.playlist.toggleLike()) + } + } + } + ) { + Icon( + painter = painterResource(if (dbPlaylist?.playlist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (dbPlaylist?.playlist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + } + ) + HorizontalDivider() + + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.id]?.state == Download.STATE_COMPLETED }) + Download.STATE_COMPLETED + else if (songs.all { + downloads[it.id]?.state == Download.STATE_QUEUED + || downloads[it.id]?.state == Download.STATE_DOWNLOADING + || downloads[it.id]?.state == Download.STATE_COMPLETED + }) + Download.STATE_DOWNLOADING + else + Download.STATE_STOPPED + } + } + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource( + R.string.remove_download_playlist_confirm, + playlist.title + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { + showRemoveDownloadDialog = false + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + TextButton( + onClick = { + showRemoveDownloadDialog = false + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + if (showRenameDialog) { + DefaultDialog( + onDismiss = { showRenameDialog = false }, + content = { + TextField( + value = renameText, + onValueChange = { renameText = it }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton(onClick = { showRenameDialog = false }) { + Text(text = stringResource(android.R.string.cancel)) + } + TextButton(onClick = { + showRenameDialog = false + coroutineScope.launch(Dispatchers.IO) { + YouTube.renamePlaylist(playlist.id, renameText) + onRefresh() + } + onDismiss() + }) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + ImportPlaylistDialog( + isVisible = showImportPlaylistDialog, + onGetSong = { + val allSongs = songs + .ifEmpty { + YouTube.playlist(playlist.id).completed().getOrNull()?.songs.orEmpty() + }.map { + it.toMediaMetadata() + } + database.transaction { + allSongs.forEach(::insert) + } + allSongs.map { it.id } + }, + playlistTitle = playlist.title, + onDismiss = { showImportPlaylistDialog = false } + ) + + if (showErrorPlaylistAddDialog) { + ListDialog( + onDismiss = { + showErrorPlaylistAddDialog = false + onDismiss() + }, + ) { + item { + ListItem( + title = stringResource(R.string.already_in_playlist), + thumbnailContent = { + Image( + painter = painterResource(R.drawable.close), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(ListThumbnailSize), + ) + }, + modifier = + Modifier + .clickable { showErrorPlaylistAddDialog = false }, + ) + } + + items(notAddedList) { song -> + ListItem( + title = song.title, + thumbnailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(ListThumbnailSize), + ) { + AsyncImage( + model = song.thumbnailUrl, + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + } + }, + subtitle = + joinByBullet( + song.artists.joinToString { it.name }, + makeTimeString(song.duration * 1000L), + ), + ) + } + } + } + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + playlist.playEndpoint?.let { + GridMenuItem( + icon = R.drawable.play, + title = R.string.play, + ) { + println("Play: ${it.playlistId}, ${it.params}") + playerConnection.playQueue(YouTubeQueue(it)) + onDismiss() + } + } + playlist.shuffleEndpoint?.let { shuffleEndpoint -> + GridMenuItem( + icon = R.drawable.shuffle, + title = R.string.shuffle + ) { + println("Shuffle: id: ${shuffleEndpoint.playlistId}, params: ${shuffleEndpoint.params}") + playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) + onDismiss() + } + } + playlist.radioEndpoint?.let { radioEndpoint -> + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio + ) { + println("Radio: ${radioEndpoint.playlistId}, ${radioEndpoint.params}") + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + onDismiss() + } + } + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next, + ) { + coroutineScope.launch { + songs + .ifEmpty { + withContext(Dispatchers.IO) { + YouTube + .playlist(playlist.id) + .completed() + .getOrNull() + ?.songs + .orEmpty() + } + }.let { songs -> + playerConnection.playNext(songs.map { it.toMediaItem() }) + } + } + onDismiss() + } + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + coroutineScope.launch { + songs + .ifEmpty { + withContext(Dispatchers.IO) { + YouTube + .playlist(playlist.id) + .completed() + .getOrNull() + ?.songs + .orEmpty() + } + }.let { songs -> + playerConnection.addToQueue(songs.map { it.toMediaItem() }) + } + } + onDismiss() + } + //GridMenuItem( + //icon = R.drawable.playlist_import, + //title = R.string.import_playlist + //) { + //showImportPlaylistDialog = true + //} + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + + if (playlist.isEditable) { + GridMenuItem( + icon = R.drawable.edit, + title = R.string.edit, + ) { + showRenameDialog = true + } + } + + if (songs.isNotEmpty()) { + DownloadGridMenu( + state = downloadState, + onDownload = { + songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }, + onRemoveDownload = { + showRemoveDownloadDialog = true + } + ) + } + + GridMenuItem( + icon = R.drawable.share, + title = R.string.share, + ) { + val intent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, playlist.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + + if (canSelect) { + GridMenuItem( + icon = R.drawable.select_all, + title = R.string.select, + ) { + onDismiss() + selectAction() + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeSongMenu.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeSongMenu.kt new file mode 100644 index 00000000..6a8f359f --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/menu/YouTubeSongMenu.kt @@ -0,0 +1,340 @@ +package com.darkxvenom.airbeats.ui.menu + +import android.annotation.SuppressLint +import android.content.Intent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +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.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.SongEntity +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.DownloadGridMenu +import com.darkxvenom.airbeats.ui.component.GridMenu +import com.darkxvenom.airbeats.ui.component.GridMenuItem +import com.darkxvenom.airbeats.ui.component.ListDialog +import com.darkxvenom.airbeats.ui.component.ListItem +import com.darkxvenom.airbeats.utils.joinByBullet +import com.darkxvenom.airbeats.utils.makeTimeString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.time.LocalDateTime + +@SuppressLint("MutableCollectionMutableState") +@Composable +fun YouTubeSongMenu( + song: SongItem, + playlist: com.darkxvenom.airbeats.innertube.models.PlaylistItem? = null, + navController: NavController, + onDismiss: () -> Unit, + onRefresh: () -> Unit = {}, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val librarySong by database.song(song.id).collectAsState(initial = null) + val download by LocalDownloadUtil.current.getDownload(song.id).collectAsState(initial = null) + val coroutineScope = rememberCoroutineScope() + val artists = + remember { + song.artists.mapNotNull { + it.id?.let { artistId -> + MediaMetadata.Artist(id = artistId, name = it.name) + } + } + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + val notAddedList by remember { + mutableStateOf(mutableListOf()) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onGetSong = { playlist -> + database.transaction { + insert(song.toMediaMetadata()) + } + coroutineScope.launch(Dispatchers.IO) { + playlist.playlist.browseId?.let { browseId -> + YouTube.addToPlaylist(browseId, song.id) + } + } + listOf(song.id) + }, + onDismiss = { showChoosePlaylistDialog = false } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false }, + ) { + items(artists) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = + Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + + ListItem( + title = song.title, + subtitle = + joinByBullet( + song.artists.joinToString { it.name }, + song.duration?.let { makeTimeString(it * 1000L) }, + ), + thumbnailContent = { + AsyncImage( + model = song.thumbnail, + contentDescription = null, + modifier = + Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + }, + trailingContent = { + IconButton( + onClick = { + database.transaction { + librarySong.let { librarySong -> + if (librarySong == null) { + insert(song.toMediaMetadata(), SongEntity::toggleLike) + } else { + update(librarySong.song.toggleLike()) + } + } + } + }, + ) { + Icon( + painter = painterResource(if (librarySong?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border), + tint = if (librarySong?.song?.liked == true) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null, + ) + } + }, + ) + + HorizontalDivider() + + GridMenu( + contentPadding = + PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + ), + ) { + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio, + ) { + playerConnection.playQueue(YouTubeQueue.radio(song.toMediaMetadata())) + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_play, + title = R.string.play_next, + ) { + playerConnection.playNext(song.toMediaItem()) + onDismiss() + } + GridMenuItem( + icon = R.drawable.queue_music, + title = R.string.add_to_queue, + ) { + playerConnection.addToQueue((song.toMediaItem())) + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist, + ) { + showChoosePlaylistDialog = true + } + if (playlist?.isEditable == true && song.setVideoId != null) { + GridMenuItem( + icon = R.drawable.delete, + title = R.string.remove_from_playlist, + ) { + coroutineScope.launch(Dispatchers.IO) { + YouTube.removeFromPlaylist(playlist.id, song.id, song.setVideoId!!) + onRefresh() + } + onDismiss() + } + } + DownloadGridMenu( + state = download?.state, + onDownload = { + database.transaction { + insert(song.toMediaMetadata()) + } + val downloadRequest = + DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + }, + ) + if (artists.isNotEmpty()) { + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist, + ) { + if (artists.size == 1) { + navController.navigate("artist/${artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } + } + } + song.album?.let { album -> + GridMenuItem( + icon = R.drawable.album, + title = R.string.view_album, + ) { + navController.navigate("album/${album.id}") + onDismiss() + } + } + if (librarySong?.song?.inLibrary != null) { + GridMenuItem( + icon = R.drawable.library_add_check, + title = R.string.remove_from_library, + ) { + database.query { + inLibrary(song.id, null) + } + } + } else { + GridMenuItem( + icon = R.drawable.library_add, + title = R.string.add_to_library, + ) { + database.transaction { + insert(song.toMediaMetadata()) + inLibrary(song.id, LocalDateTime.now()) + } + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share, + ) { + val intent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, song.shareLink) + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/player/MiniPlayer.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/player/MiniPlayer.kt new file mode 100644 index 00000000..68f6af64 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/player/MiniPlayer.kt @@ -0,0 +1,529 @@ +package com.darkxvenom.airbeats.ui.player + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +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.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.isSystemInDarkTheme +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +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.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.media3.common.Player +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.DarkModeKey +import com.darkxvenom.airbeats.constants.MiniPlayerThumbnailShapeKey +import com.darkxvenom.airbeats.constants.DefaultMiniPlayerThumbnailShape +import com.darkxvenom.airbeats.constants.PureBlackKey +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.ui.screens.settings.DarkMode +import com.darkxvenom.airbeats.utils.getMiniPlayerThumbnailShape +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue +import kotlin.math.exp +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun MiniPlayer( + position: Long, + duration: Long, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val playbackState by playerConnection.playbackState.collectAsState() + val error by playerConnection.error.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val canSkipNext by playerConnection.canSkipNext.collectAsState() + val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + + // Obtener el estado del tema para calcular el color de fondo correcto + val isSystemInDarkTheme = isSystemInDarkTheme() + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) + + val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + + // Obtener la forma del thumbnail del MiniPlayer + val miniPlayerThumbnailShapeState = rememberPreference( + key = MiniPlayerThumbnailShapeKey, + defaultValue = DefaultMiniPlayerThumbnailShape + ) + + val miniPlayerThumbnailShape = remember(miniPlayerThumbnailShapeState.value, isPlaying) { + if (isPlaying) { + getMiniPlayerThumbnailShape(miniPlayerThumbnailShapeState.value) + } else { + MaterialShapes.Circle + } + } + + // Calcular el color de fondo correcto + val miniPlayerBackgroundColor = when { + useDarkTheme && pureBlack -> Color.Black.copy(alpha = 0.95f) + else -> MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.95f) + } + + val currentView = LocalView.current + val layoutDirection = LocalLayoutDirection.current + val coroutineScope = rememberCoroutineScope() + + val configuration = LocalConfiguration.current + val isTabletLandscape = configuration.screenWidthDp >= 600 && + configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + + val offsetXAnimatable = remember { Animatable(0f) } + var dragStartTime by remember { mutableLongStateOf(0L) } + var totalDragDistance by remember { mutableFloatStateOf(0f) } + + val animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow + ) + + val overlayAlpha by animateFloatAsState( + targetValue = if (isPlaying) 0.0f else 0.4f, + label = "overlay_alpha", + animationSpec = animationSpec + ) + + // Animación INFINITA de rotación para el thumbnail + // Se ejecuta continuamente mientras isPlaying = true + val infiniteTransition = rememberInfiniteTransition(label = "thumbnail_rotation") + val thumbnailRotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 8000, // 8 segundos para una rotación completa (velocidad lenta) + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + + // Convertir RoundedPolygon a Shape usando la API oficial + // Cambia a Square cuando está en pausa, usa la forma seleccionada cuando está reproduciendo + val currentThumbnailShape = remember(isPlaying, miniPlayerThumbnailShape) { + if (isPlaying) { + miniPlayerThumbnailShape + } else { + MaterialShapes.Square + } + }.toShape() + + /** + * Calculates the auto-swipe threshold based on swipe sensitivity. + */ + fun calculateAutoSwipeThreshold(swipeSensitivity: Float): Int { + return (600 / (1f + exp(-(-11.44748 * swipeSensitivity + 9.04945)))).roundToInt() + } + val autoSwipeThreshold = calculateAutoSwipeThreshold(0.73f) + + Box( + modifier = modifier + .fillMaxWidth() + .height(80.dp) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(horizontal = 16.dp, vertical = 8.dp) + .background(Color.Transparent) + ) { + Surface( + modifier = Modifier + .then( + if (isTabletLandscape) { + Modifier + .width(480.dp) + .align(Alignment.CenterEnd) + } else { + Modifier.fillMaxWidth() + } + ) + .height(64.dp) + .offset { IntOffset(offsetXAnimatable.value.roundToInt(), 0) } + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(32.dp), + clip = false + ), + tonalElevation = 2.dp, + shape = RoundedCornerShape(32.dp), + color = Color.Transparent + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(miniPlayerBackgroundColor) + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { + dragStartTime = System.currentTimeMillis() + totalDragDistance = 0f + }, + onDragCancel = { + coroutineScope.launch { + offsetXAnimatable.animateTo( + targetValue = 0f, + animationSpec = animationSpec + ) + } + }, + onHorizontalDrag = { _, dragAmount -> + val adjustedDragAmount = + if (layoutDirection == LayoutDirection.Rtl) -dragAmount else dragAmount + val allowLeft = adjustedDragAmount < 0 && canSkipNext + val allowRight = adjustedDragAmount > 0 && canSkipPrevious + if (allowLeft || allowRight) { + totalDragDistance += adjustedDragAmount.absoluteValue + coroutineScope.launch { + offsetXAnimatable.snapTo(offsetXAnimatable.value + adjustedDragAmount) + } + } + }, + onDragEnd = { + val dragDuration = System.currentTimeMillis() - dragStartTime + val velocity = + if (dragDuration > 0) totalDragDistance / dragDuration else 0f + val currentOffset = offsetXAnimatable.value + + val minDistanceThreshold = 50f + val velocityThreshold = (0.73f * -8.25f) + 8.5f + + val shouldChangeSong = ( + currentOffset.absoluteValue > minDistanceThreshold && + velocity > velocityThreshold + ) || (currentOffset.absoluteValue > autoSwipeThreshold) + + if (shouldChangeSong) { + val isRightSwipe = currentOffset > 0 + + if (isRightSwipe && canSkipPrevious) { + playerConnection.player.seekToPreviousMediaItem() + } else if (!isRightSwipe && canSkipNext) { + playerConnection.player.seekToNext() + } + } + + coroutineScope.launch { + offsetXAnimatable.animateTo( + targetValue = 0f, + animationSpec = animationSpec + ) + } + } + ) + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(48.dp) + ) { + if (duration > 0) { + CircularProgressIndicator( + progress = { (position.toFloat() / duration).coerceIn(0f, 1f) }, + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp, + trackColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(40.dp) + .rotate(if (isPlaying) thumbnailRotation else 0f) // Solo rota cuando está reproduciendo + .clip(currentThumbnailShape) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + shape = currentThumbnailShape + ) + .clickable { + if (playbackState == Player.STATE_ENDED) { + playerConnection.player.seekTo(0, 0) + playerConnection.player.playWhenReady = true + } else { + playerConnection.player.togglePlayPause() + } + } + ) { + mediaMetadata?.let { metadata -> + AsyncImage( + model = metadata.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(currentThumbnailShape) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = overlayAlpha), + shape = currentThumbnailShape + ) + ) + + androidx.compose.animation.AnimatedVisibility( + visible = playbackState == Player.STATE_ENDED || !isPlaying, + enter = fadeIn(), + exit = fadeOut() + ) { + Icon( + painter = painterResource( + if (playbackState == Player.STATE_ENDED) { + R.drawable.replay + } else { + R.drawable.play + } + ), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + mediaMetadata?.let { metadata -> + AnimatedContent( + targetState = metadata.title, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "", + ) { title -> + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee(), + ) + } + + if (metadata.artists.any { it.name.isNotBlank() }) { + AnimatedContent( + targetState = metadata.artists.joinToString { it.name }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "", + ) { artists -> + Text( + text = artists, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee(), + ) + } + } + + androidx.compose.animation.AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Text( + text = "Error playing", + color = MaterialTheme.colorScheme.error, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = { playerConnection.toggleLike() }, + modifier = Modifier.size(36.dp) + ) { + Icon( + painter = painterResource( + if (currentSong?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border + ), + contentDescription = if (currentSong?.song?.liked == true) "Unlike" else "Like", + tint = if (currentSong?.song?.liked == true) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + }, + modifier = Modifier.size(18.dp) + ) + } + + IconButton( + enabled = canSkipNext, + onClick = { playerConnection.player.seekToNext() }, + modifier = Modifier.size(36.dp) + ) { + Icon( + painter = painterResource(R.drawable.skip_next), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp) + ) + } + } + } + } + + if (offsetXAnimatable.value.absoluteValue > 50f) { + Box( + modifier = Modifier + .align(if (offsetXAnimatable.value > 0) Alignment.CenterStart else Alignment.CenterEnd) + .padding(horizontal = 24.dp) + ) { + Icon( + painter = painterResource( + if (offsetXAnimatable.value > 0) R.drawable.skip_previous else R.drawable.skip_next + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary.copy( + alpha = (offsetXAnimatable.value.absoluteValue / autoSwipeThreshold).coerceIn(0f, 1f) + ), + modifier = Modifier.size(24.dp) + ) + } + } + } +} + +@Composable +fun MiniMediaInfo( + mediaMetadata: MediaMetadata, + error: androidx.media3.common.PlaybackException?, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Box(modifier = Modifier.padding(6.dp)) { + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + androidx.compose.animation.AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + Modifier + .size(48.dp) + .background( + color = Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(ThumbnailCornerRadius), + ), + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/player/PlaybackError.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/player/PlaybackError.kt new file mode 100644 index 00000000..c54b4ccf --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/player/PlaybackError.kt @@ -0,0 +1,45 @@ +package com.darkxvenom.airbeats.ui.player + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.PlaybackException +import com.darkxvenom.airbeats.R + +@Composable +fun PlaybackError( + error: PlaybackException, + retry: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.pointerInput(Unit) { + detectTapGestures( + onTap = { retry() }, + ) + }, + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + + Text( + text = error.cause?.cause?.message ?: stringResource(R.string.error_unknown), + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/player/Player.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/player/Player.kt new file mode 100644 index 00000000..6b259f5f --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/player/Player.kt @@ -0,0 +1,1533 @@ +package com.darkxvenom.airbeats.ui.player + +import android.content.res.Configuration +import android.graphics.drawable.BitmapDrawable +import android.text.format.Formatter + +import android.widget.Toast +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +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.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +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.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.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.window.DialogProperties +import androidx.core.graphics.ColorUtils +import androidx.core.net.toUri +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.Player.STATE_ENDED +import androidx.media3.common.Player.STATE_READY +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import androidx.palette.graphics.Palette +import coil.ImageLoader +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.DarkModeKey +import com.darkxvenom.airbeats.constants.DefaultPlayPauseButtonShape +import com.darkxvenom.airbeats.constants.DefaultSmallButtonsShape +import com.darkxvenom.airbeats.constants.PlayPauseButtonShapeKey +import com.darkxvenom.airbeats.constants.PlayerBackgroundStyle +import com.darkxvenom.airbeats.constants.PlayerBackgroundStyleKey +import com.darkxvenom.airbeats.constants.PlayerButtonsStyle +import com.darkxvenom.airbeats.constants.PlayerButtonsStyleKey +import com.darkxvenom.airbeats.constants.PlayerHorizontalPadding +import com.darkxvenom.airbeats.constants.PlayerTextAlignmentKey +import com.darkxvenom.airbeats.constants.PureBlackKey +import com.darkxvenom.airbeats.constants.QueuePeekHeight +import com.darkxvenom.airbeats.constants.ShowLyricsKey +import com.darkxvenom.airbeats.constants.SliderStyle +import com.darkxvenom.airbeats.constants.SliderStyleKey +import com.darkxvenom.airbeats.constants.SmallButtonsShapeKey +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.extensions.toggleRepeatMode +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.ui.component.BottomSheet +import com.darkxvenom.airbeats.ui.component.BottomSheetState +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.PlayerSliderTrack +import com.darkxvenom.airbeats.ui.component.ResizableIconButton +import com.darkxvenom.airbeats.ui.component.rememberBottomSheetState +import com.darkxvenom.airbeats.ui.menu.PlayerMenu +import com.darkxvenom.airbeats.ui.screens.settings.DarkMode +import com.darkxvenom.airbeats.ui.screens.settings.PlayerTextAlignment +import com.darkxvenom.airbeats.ui.theme.PlayerColorExtractor +import com.darkxvenom.airbeats.ui.theme.PlayerSliderColors +import com.darkxvenom.airbeats.ui.theme.extractGradientColors +import com.darkxvenom.airbeats.utils.getPlayPauseShape +import com.darkxvenom.airbeats.utils.getSmallButtonShape +import com.darkxvenom.airbeats.utils.makeTimeString +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import me.saket.squiggles.SquigglySlider +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun BottomSheetPlayer( + state: BottomSheetState, + navController: NavController, + onOpenFullscreenLyrics: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val database = LocalDatabase.current + val menuState = LocalMenuState.current + + val clipboardManager = LocalClipboardManager.current + + var showFullscreenLyrics by remember { mutableStateOf(false) } + val playerConnection = LocalPlayerConnection.current ?: return + + val playerTextAlignment by rememberEnumPreference( + PlayerTextAlignmentKey, + PlayerTextAlignment.CENTER + ) + + val playerBackground by rememberEnumPreference( + key = PlayerBackgroundStyleKey, + defaultValue = PlayerBackgroundStyle.DEFAULT + ) + + val isSystemInDarkTheme = isSystemInDarkTheme() + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) + val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + val onBackgroundColor = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.secondary + else -> + if (useDarkTheme) + MaterialTheme.colorScheme.onSurface + else + MaterialTheme.colorScheme.onPrimary + } + val useBlackBackground = + remember(isSystemInDarkTheme, darkTheme, pureBlack) { + val useDarkTheme = + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + useDarkTheme && pureBlack + } + val backgroundColor = if (useBlackBackground && state.value > state.collapsedBound) { + lerp(MaterialTheme.colorScheme.surfaceContainer, Color.Black, state.progress) + } else { + MaterialTheme.colorScheme.surfaceContainer + } + + val playbackState by playerConnection.playbackState.collectAsState() + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + val automix by playerConnection.service.automixItems.collectAsState() + val repeatMode by playerConnection.repeatMode.collectAsState() + + val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() + val canSkipNext by playerConnection.canSkipNext.collectAsState() + + val showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) + val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.SQUIGGLY) + + var position by rememberSaveable(playbackState) { + mutableLongStateOf(playerConnection.player.currentPosition) + } + var duration by rememberSaveable(playbackState) { + mutableLongStateOf(playerConnection.player.duration) + } + var sliderPosition by remember { + mutableStateOf(null) + } + + var gradientColors by remember { + mutableStateOf>(emptyList()) + } + + var changeColor by remember { + mutableStateOf(false) + } + + // Animations for background effects + var backgroundImageUrl by remember { mutableStateOf(null) } + val blurRadius by animateDpAsState( + targetValue = if (state.isExpanded && playerBackground == PlayerBackgroundStyle.BLUR) 150.dp else 0.dp, + animationSpec = tween(durationMillis = 800, easing = FastOutSlowInEasing), + label = "blurRadius" + ) + + val backgroundAlpha by animateFloatAsState( + targetValue = if (state.isExpanded && playerBackground != PlayerBackgroundStyle.DEFAULT) 1f else 0f, + animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), + label = "backgroundAlpha" + ) + + val overlayAlpha by animateFloatAsState( + targetValue = when { + !state.isExpanded -> 0f + playerBackground == PlayerBackgroundStyle.BLUR -> 0.3f + playerBackground == PlayerBackgroundStyle.GRADIENT && gradientColors.size >= 2 -> 0.2f + else -> 0f + }, + animationSpec = tween(durationMillis = 700, easing = FastOutSlowInEasing), + label = "overlayAlpha" + ) + + val playerButtonsStyle by rememberEnumPreference( + key = PlayerButtonsStyleKey, + defaultValue = PlayerButtonsStyle.DEFAULT + ) + + if (!canSkipNext && automix.isNotEmpty()) { + playerConnection.service.addToQueueAutomix(automix[0], 0) + } + + // Obtener el color del tema antes de LaunchedEffect + val surfaceColor = MaterialTheme.colorScheme.surface + val fallbackColorArgb = surfaceColor.toArgb() + + LaunchedEffect(mediaMetadata, playerBackground, fallbackColorArgb) { + // Update image URL for smooth transitions + backgroundImageUrl = mediaMetadata?.thumbnailUrl + + if (useBlackBackground && playerBackground != PlayerBackgroundStyle.BLUR) { + gradientColors = listOf(Color.Black, Color.Black) + } + if (useBlackBackground && playerBackground != PlayerBackgroundStyle.GRADIENT) { + gradientColors = listOf(Color.Black, Color.Black) + } else if (playerBackground == PlayerBackgroundStyle.GRADIENT) { + withContext(Dispatchers.IO) { + val result = runCatching { + ImageLoader(context) + .execute( + ImageRequest + .Builder(context) + .data(mediaMetadata?.thumbnailUrl) + .allowHardware(false) + .build(), + ).drawable as? BitmapDrawable + }.getOrNull() + + result?.bitmap?.let { bitmap -> + val palette = Palette.from(bitmap) + .maximumColorCount(8) + .resizeBitmapArea(100 * 100) + .generate() + + val extractedColors = PlayerColorExtractor.extractGradientColors( + palette = palette, + fallbackColor = fallbackColorArgb + ) + + withContext(Dispatchers.Main) { + gradientColors = extractedColors + } + } + } + } else { + gradientColors = emptyList() + } + } + + val TextBackgroundColor = + when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.onBackground + PlayerBackgroundStyle.BLUR -> Color.White + else -> { + val whiteContrast = + if (gradientColors.size >= 2) { + ColorUtils.calculateContrast( + gradientColors.first().toArgb(), + Color.White.toArgb(), + ) + } else { + 2.0 + } + val blackContrast: Double = + if (gradientColors.size >= 2) { + ColorUtils.calculateContrast( + gradientColors.last().toArgb(), + Color.Black.toArgb(), + ) + } else { + 2.0 + } + if (gradientColors.size >= 2 && + whiteContrast < 2f && + blackContrast > 2f + ) { + changeColor = true + Color.Black + } else if (whiteContrast > 2f && blackContrast < 2f) { + changeColor = true + Color.White + } else { + changeColor = false + Color.White + } + } + } + + val icBackgroundColor = + when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.surface + PlayerBackgroundStyle.BLUR -> Color.Black + else -> { + val whiteContrast = + if (gradientColors.size >= 2) { + ColorUtils.calculateContrast( + gradientColors.first().toArgb(), + Color.White.toArgb(), + ) + } else { + 2.0 + } + val blackContrast: Double = + if (gradientColors.size >= 2) { + ColorUtils.calculateContrast( + gradientColors.last().toArgb(), + Color.Black.toArgb(), + ) + } else { + 2.0 + } + if (gradientColors.size >= 2 && + whiteContrast < 2f && + blackContrast > 2f + ) { + changeColor = true + Color.White + } else if (whiteContrast > 2f && blackContrast < 2f) { + changeColor = true + Color.Black + } else { + changeColor = false + Color.Black + } + } + } + + // Sistema de color scheme con PRIMARY y TERTIARY colors + val (textButtonColor, iconButtonColor) = when (playerButtonsStyle) { + PlayerButtonsStyle.DEFAULT -> + if (useDarkTheme) Pair(Color.White, Color.Black) + else Pair(Color.Black, Color.White) + PlayerButtonsStyle.PRIMARY -> Pair( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.onPrimary + ) + PlayerButtonsStyle.TERTIARY -> Pair( + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.onTertiary + ) + } + + val download by LocalDownloadUtil.current.getDownload(mediaMetadata?.id ?: "") + .collectAsState(initial = null) + + val sleepTimerEnabled = + remember( + playerConnection.service.sleepTimer.triggerTime, + playerConnection.service.sleepTimer.pauseWhenSongEnd + ) { + playerConnection.service.sleepTimer.isActive + } + + var sleepTimerTimeLeft by remember { + mutableLongStateOf(0L) + } + + LaunchedEffect(sleepTimerEnabled) { + if (sleepTimerEnabled) { + while (isActive) { + sleepTimerTimeLeft = + if (playerConnection.service.sleepTimer.pauseWhenSongEnd) { + playerConnection.player.duration - playerConnection.player.currentPosition + } else { + playerConnection.service.sleepTimer.triggerTime - System.currentTimeMillis() + } + delay(1000L) + } + } + } + + var showSleepTimerDialog by remember { + mutableStateOf(false) + } + + var sleepTimerValue by remember { + mutableFloatStateOf(30f) + } + if (showSleepTimerDialog) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { showSleepTimerDialog = false }, + icon = { + Icon( + painter = painterResource(R.drawable.bedtime), + contentDescription = null + ) + }, + title = { Text(stringResource(R.string.sleep_timer)) }, + confirmButton = { + TextButton( + onClick = { + showSleepTimerDialog = false + playerConnection.service.sleepTimer.start(sleepTimerValue.roundToInt()) + }, + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton( + onClick = { showSleepTimerDialog = false }, + ) { + Text(stringResource(android.R.string.cancel)) + } + }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = pluralStringResource( + R.plurals.minute, + sleepTimerValue.roundToInt(), + sleepTimerValue.roundToInt() + ), + style = MaterialTheme.typography.bodyLarge, + ) + + Slider( + value = sleepTimerValue, + onValueChange = { sleepTimerValue = it }, + valueRange = 5f..120f, + steps = (120 - 5) / 5 - 1, + ) + + OutlinedButton( + onClick = { + showSleepTimerDialog = false + playerConnection.service.sleepTimer.start(-1) + }, + ) { + Text(stringResource(R.string.end_of_song)) + } + } + }, + ) + } + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + val smallButtonsShapeState = rememberPreference( + key = SmallButtonsShapeKey, + defaultValue = DefaultSmallButtonsShape + ) + + val smallButtonShape = remember(smallButtonsShapeState.value) { + getSmallButtonShape(smallButtonsShapeState.value) + } + + val playPauseShapeState = rememberPreference( + key = PlayPauseButtonShapeKey, + defaultValue = DefaultPlayPauseButtonShape + ) + + val playPauseShape = remember(playPauseShapeState.value) { + getPlayPauseShape(playPauseShapeState.value) + } + + val infiniteTransition = rememberInfiniteTransition(label = "play_pause_rotation") + val playPauseRotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 9000, // 9 seconds for a full rotation + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + + // Forma dinámica: cuando está reproduciendo usa la forma seleccionada + // Cuando está en pausa usa Square + val currentPlayPauseShape = remember(isPlaying, playPauseShape) { + if (isPlaying) { + playPauseShape + } else { + MaterialShapes.Square + } + } + + // Function to create the modifier for small buttons + val smallButtonModifier = @Composable { + Modifier + .size(42.dp) + .clip(smallButtonShape.toShape()) + .background(textButtonColor) + } + + LaunchedEffect(playbackState) { + if (playbackState == STATE_READY) { + while (isActive) { + delay(100) + position = playerConnection.player.currentPosition + duration = playerConnection.player.duration + } + } + } + + val currentFormat by playerConnection.currentFormat.collectAsState(initial = null) + + var showDetailsDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showDetailsDialog) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { showDetailsDialog = false }, + containerColor = if (useBlackBackground) Color.Black else AlertDialogDefaults.containerColor, + icon = { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + ) + }, + confirmButton = { + TextButton( + onClick = { showDetailsDialog = false }, + ) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { + Column( + modifier = + Modifier + .sizeIn(minWidth = 280.dp, maxWidth = 560.dp) + .verticalScroll(rememberScrollState()), + ) { + listOf( + stringResource(R.string.song_title) to mediaMetadata?.title, + stringResource(R.string.song_artists) to mediaMetadata?.artists?.joinToString { it.name }, + stringResource(R.string.media_id) to mediaMetadata?.id, + "Itag" to currentFormat?.itag?.toString(), + stringResource(R.string.mime_type) to currentFormat?.mimeType, + stringResource(R.string.codecs) to currentFormat?.codecs, + stringResource(R.string.bitrate) to currentFormat?.bitrate?.let { "${it / 1000} Kbps" }, + stringResource(R.string.sample_rate) to currentFormat?.sampleRate?.let { "$it Hz" }, + stringResource(R.string.loudness) to currentFormat?.loudnessDb?.let { "$it dB" }, + stringResource(R.string.volume) to "${(playerConnection.player.volume * 100).toInt()}%", + stringResource(R.string.file_size) to + currentFormat?.contentLength?.let { + Formatter.formatShortFileSize( + context, + it + ) + }, + ).forEach { (label, text) -> + val displayText = text ?: stringResource(R.string.unknown) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = displayText, + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + clipboardManager.setText(AnnotatedString(displayText)) + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT) + .show() + }, + ), + ) + Spacer(Modifier.height(8.dp)) + } + } + }, + ) + } + + val queueSheetState = + rememberBottomSheetState( + dismissedBound = QueuePeekHeight + WindowInsets.systemBars.asPaddingValues() + .calculateBottomPadding(), + expandedBound = state.expandedBound, + ) + + val bottomSheetBackgroundColor = when (playerBackground) { + PlayerBackgroundStyle.BLUR, PlayerBackgroundStyle.GRADIENT -> + MaterialTheme.colorScheme.surfaceContainer + else -> + if (useBlackBackground) Color.Black + else MaterialTheme.colorScheme.surfaceContainer + } + + BottomSheet( + state = state, + modifier = modifier, + background = { + Box( + modifier = Modifier + .fillMaxSize() + .background(bottomSheetBackgroundColor) + ) { + when (playerBackground) { + PlayerBackgroundStyle.BLUR -> { + AnimatedContent( + targetState = mediaMetadata?.thumbnailUrl, + transitionSpec = { + fadeIn(tween(800)).togetherWith(fadeOut(tween(800))) + }, + label = "blurBackground" + ) { thumbnailUrl -> + if (thumbnailUrl != null) { + Box(modifier = Modifier.alpha(backgroundAlpha)) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(thumbnailUrl) + .size(100, 100) + .allowHardware(false) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(if (useDarkTheme) 150.dp else 100.dp) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + ) + } + } + } + } + PlayerBackgroundStyle.GRADIENT -> { + AnimatedContent( + targetState = gradientColors, + transitionSpec = { + fadeIn(tween(800)).togetherWith(fadeOut(tween(800))) + }, + label = "gradientBackground" + ) { colors -> + if (colors.isNotEmpty()) { + val gradientColorStops = if (colors.size >= 3) { + arrayOf( + 0.0f to colors[0], + 0.5f to colors[1], + 1.0f to colors[2] + ) + } else { + arrayOf( + 0.0f to colors[0], + 0.6f to colors[0].copy(alpha = 0.7f), + 1.0f to Color.Black + ) + } + Box( + Modifier + .fillMaxSize() + .alpha(backgroundAlpha) + .background(Brush.verticalGradient(colorStops = gradientColorStops)) + .background(Color.Black.copy(alpha = 0.2f)) + ) + } + } + } + else -> { + PlayerBackgroundStyle.DEFAULT + } + } + } + }, + onDismiss = { + playerConnection.service.clearAutomix() + playerConnection.player.stop() + playerConnection.player.clearMediaItems() + }, + collapsedContent = { + MiniPlayer( + position = position, + duration = duration, + ) + }, + ) { + val controlsContent: @Composable ColumnScope.(MediaMetadata) -> Unit = { mediaMetadata -> + val playPauseRoundness by animateDpAsState( + targetValue = if (isPlaying) 24.dp else 36.dp, + animationSpec = tween(durationMillis = 90, easing = LinearEasing), + label = "playPauseRoundness", + ) + + Row( + horizontalArrangement = + when (playerTextAlignment) { + PlayerTextAlignment.SIDED -> Arrangement.Start + PlayerTextAlignment.CENTER -> Arrangement.Center + }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = PlayerHorizontalPadding), + ) { + AnimatedContent( + targetState = mediaMetadata.title, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "", + ) { title -> + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = onBackgroundColor, + modifier = + Modifier + .basicMarquee() + .clickable(enabled = mediaMetadata.album != null) { + navController.navigate("album/${mediaMetadata.album!!.id}") + state.collapseSoft() + }, + ) + } + } + + Spacer(Modifier.height(6.dp)) + + Row( + horizontalArrangement = + when (playerTextAlignment) { + PlayerTextAlignment.SIDED -> Arrangement.Start + PlayerTextAlignment.CENTER -> Arrangement.Center + }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = PlayerHorizontalPadding), + ) { + mediaMetadata.artists.fastForEachIndexed { index, artist -> + AnimatedContent( + targetState = artist.name, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "", + ) { name -> + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + color = onBackgroundColor, + maxLines = 1, + modifier = + Modifier.clickable(enabled = artist.id != null) { + navController.navigate("artist/${artist.id}") + state.collapseSoft() + }, + ) + } + + if (index != mediaMetadata.artists.lastIndex) { + AnimatedContent( + targetState = ", ", + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "", + ) { comma -> + Text( + text = comma, + style = MaterialTheme.typography.titleMedium, + color = onBackgroundColor, + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = PlayerHorizontalPadding), + ) { + Box( + modifier = smallButtonModifier() + .clickable { + playerConnection.service.startRadioSeamlessly() + }, + ) { + Image( + painter = painterResource(R.drawable.radio), + contentDescription = null, + colorFilter = ColorFilter.tint(iconButtonColor), + modifier = Modifier + .align(Alignment.Center) + .size(24.dp), + ) + } + + Spacer(modifier = Modifier.size(12.dp)) + + Box( + modifier = smallButtonModifier() + .clickable { + if (download?.state == Download.STATE_COMPLETED) { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + mediaMetadata.id, + false, + ) + } else { + database.transaction { + insert(mediaMetadata) + } + val downloadRequest = + DownloadRequest + .Builder(mediaMetadata.id, mediaMetadata.id.toUri()) + .setCustomCacheKey(mediaMetadata.id) + .setData(mediaMetadata.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + ) { + if (download?.state == Download.STATE_DOWNLOADING) { + val progress = download!!.percentDownloaded / 100f + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ), + label = "downloadProgress" + ) + + Canvas( + modifier = Modifier + .fillMaxSize() + .padding(2.dp) + ) { + val strokeWidth = 3.dp.toPx() + val radius = (size.minDimension - strokeWidth) / 2 + val center = Offset(size.width / 2, size.height / 2) + + // Background circle (gray) + drawCircle( + color = iconButtonColor.copy(alpha = 0.3f), + radius = radius, + center = center, + style = Stroke(width = strokeWidth) + ) + + // Progress circle + if (animatedProgress > 0f) { + drawArc( + color = iconButtonColor, + startAngle = -90f, + sweepAngle = 360f * animatedProgress, + useCenter = false, + topLeft = Offset( + center.x - radius, + center.y - radius + ), + size = Size(radius * 2, radius * 2), + style = Stroke( + width = strokeWidth, + cap = StrokeCap.Round + ) + ) + } + } + } + + val iconResource = when (download?.state) { + Download.STATE_COMPLETED -> R.drawable.offline + Download.STATE_DOWNLOADING -> R.drawable.pause + Download.STATE_FAILED -> R.drawable.error + else -> R.drawable.download + } + + val iconAlpha by animateFloatAsState( + targetValue = if (download?.state == Download.STATE_DOWNLOADING) 0.8f else 1f, + animationSpec = tween(durationMillis = 200), + label = "iconAlpha" + ) + + val iconScale by animateFloatAsState( + targetValue = if (download?.state == Download.STATE_DOWNLOADING) 0.9f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "iconScale" + ) + + Image( + painter = painterResource(iconResource), + contentDescription = when (download?.state) { + Download.STATE_COMPLETED -> stringResource(R.string.download) + Download.STATE_DOWNLOADING -> stringResource(R.string.downloading) + Download.STATE_FAILED -> stringResource(R.string.download_errorup) + else -> stringResource(R.string.download) + }, + colorFilter = ColorFilter.tint( + when (download?.state) { + Download.STATE_FAILED -> MaterialTheme.colorScheme.error + else -> iconButtonColor + } + ), + modifier = Modifier + .align(Alignment.Center) + .size(24.dp) + .scale(iconScale) + .alpha(iconAlpha), + ) + + // Small progress text + if (download?.state == Download.STATE_DOWNLOADING) { + val progress = (download!!.percentDownloaded).toInt() + Text( + text = "$progress%", + style = MaterialTheme.typography.labelSmall, + color = iconButtonColor, + fontSize = 8.sp, + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = (-2).dp) + ) + } + } + + Spacer(modifier = Modifier.size(12.dp)) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(42.dp) + .clip(smallButtonShape.toShape()) + .background(MaterialTheme.colorScheme.secondaryContainer), + ) { + AnimatedContent( + label = "sleepTimer", + targetState = sleepTimerEnabled, + ) { sleepTimerEnabled -> + if (sleepTimerEnabled) { + Text( + text = makeTimeString(sleepTimerTimeLeft), + style = MaterialTheme.typography.labelLarge, + color = onBackgroundColor, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable(onClick = playerConnection.service.sleepTimer::clear) + .basicMarquee(), + ) + } else { + Box( + modifier = smallButtonModifier() + .clickable { + showSleepTimerDialog = true + }, + ) { + Image( + painter = painterResource(R.drawable.bedtime), + colorFilter = ColorFilter.tint(iconButtonColor), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .size(24.dp), + ) + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Box( + contentAlignment = Alignment.Center, + modifier = smallButtonModifier() + .clickable { + menuState.show { + PlayerMenu( + mediaMetadata = mediaMetadata, + navController = navController, + playerBottomSheetState = state, + onShowDetailsDialog = { showDetailsDialog = true }, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Image( + painter = painterResource(R.drawable.more_horiz), + contentDescription = null, + colorFilter = ColorFilter.tint(iconButtonColor), + ) + } + } + + Spacer(Modifier.height(6.dp)) + + when (sliderStyle) { + SliderStyle.DEFAULT -> { + Slider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { + sliderPosition = it.toLong() + }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + colors = PlayerSliderColors.getSliderColors( + textButtonColor = TextBackgroundColor, + playerBackground = playerBackground, + useDarkTheme = useDarkTheme + ), + modifier = Modifier.padding(horizontal = PlayerHorizontalPadding), + ) + } + + SliderStyle.SQUIGGLY -> { + SquigglySlider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { + sliderPosition = it.toLong() + }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + colors = PlayerSliderColors.getSliderColors( + textButtonColor = TextBackgroundColor, + playerBackground = playerBackground, + useDarkTheme = useDarkTheme + ), + modifier = Modifier.padding(horizontal = PlayerHorizontalPadding), + squigglesSpec = + SquigglySlider.SquigglesSpec( + amplitude = if (isPlaying) (2.dp).coerceAtLeast(2.dp) else 0.dp, + strokeWidth = 3.dp, + ), + ) + } + + SliderStyle.SLIM -> { + Slider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), + onValueChange = { + sliderPosition = it.toLong() + }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player.seekTo(it) + position = it + } + sliderPosition = null + }, + thumb = { Spacer(modifier = Modifier.size(0.dp)) }, + track = { sliderState -> + PlayerSliderTrack( + sliderState = sliderState, + colors = PlayerSliderColors.getSliderColors( + textButtonColor = TextBackgroundColor, + playerBackground = playerBackground, + useDarkTheme = useDarkTheme + ) + ) + }, + modifier = Modifier.padding(horizontal = PlayerHorizontalPadding) + ) + } + } + + Spacer(Modifier.height(4.dp)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = PlayerHorizontalPadding + 4.dp), + ) { + Text( + text = makeTimeString(sliderPosition ?: position), + style = MaterialTheme.typography.labelMedium, + color = TextBackgroundColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = if (duration != C.TIME_UNSET) makeTimeString(duration) else "", + style = MaterialTheme.typography.labelMedium, + color = TextBackgroundColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(Modifier.height(12.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = PlayerHorizontalPadding), + ) { + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = when (repeatMode) { + Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ALL -> R.drawable.repeat + Player.REPEAT_MODE_ONE -> R.drawable.repeat_one + else -> throw IllegalStateException() + }, + color = TextBackgroundColor, + modifier = Modifier + .size(32.dp) + .padding(4.dp) + .align(Alignment.Center) + .alpha(if (repeatMode == Player.REPEAT_MODE_OFF) 0.5f else 1f), + onClick = { + playerConnection.player.toggleRepeatMode() + }, + ) + } + + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = R.drawable.skip_previous, + enabled = canSkipPrevious, + color = TextBackgroundColor, + modifier = + Modifier + .size(32.dp) + .align(Alignment.Center), + onClick = playerConnection::seekToPrevious, + ) + } + + Spacer(Modifier.width(8.dp)) + + Box( + modifier = + Modifier + .size(85.dp) + .rotate(if (isPlaying) playPauseRotation else 0f) + .clip(currentPlayPauseShape.toShape()) + .background(textButtonColor) + .clickable { + if (playbackState == STATE_ENDED) { + playerConnection.player.seekTo(0, 0) + playerConnection.player.playWhenReady = true + } else { + playerConnection.player.togglePlayPause() + } + }, + ) { + Image( + painter = + painterResource( + if (playbackState == + STATE_ENDED + ) { + R.drawable.replay + } else if (isPlaying) { + R.drawable.pause + } else { + R.drawable.play + }, + ), + contentDescription = null, + colorFilter = ColorFilter.tint(iconButtonColor), + modifier = + Modifier + .align(Alignment.Center) + .size(36.dp) + .rotate(if (isPlaying) -playPauseRotation else 0f), + ) + } + + Spacer(Modifier.width(8.dp)) + + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = R.drawable.skip_next, + enabled = canSkipNext, + color = TextBackgroundColor, + modifier = + Modifier + .size(32.dp) + .align(Alignment.Center), + onClick = playerConnection::seekToNext, + ) + } + + Box(modifier = Modifier.weight(1f)) { + ResizableIconButton( + icon = if (currentSong?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border, + color = if (currentSong?.song?.liked == true) MaterialTheme.colorScheme.error else TextBackgroundColor, + modifier = + Modifier + .size(32.dp) + .padding(4.dp) + .align(Alignment.Center), + onClick = playerConnection::toggleLike, + ) + } + } + } + + // Animated background effects + Box( + modifier = Modifier.fillMaxSize() + ) { + // Background with blurred image + AnimatedVisibility( + visible = playerBackground == PlayerBackgroundStyle.BLUR && backgroundImageUrl != null, + enter = fadeIn(tween(600)), + exit = fadeOut(tween(400)) + ) { + AsyncImage( + model = backgroundImageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(blurRadius) + .alpha(backgroundAlpha) + ) + } + + // Animated gradient background + AnimatedVisibility( + visible = playerBackground == PlayerBackgroundStyle.GRADIENT && gradientColors.size >= 2, + enter = fadeIn(tween(800)), + exit = fadeOut(tween(600)) + ) { + val animatedGradientColors = gradientColors.map { color -> + androidx.compose.animation.animateColorAsState( + targetValue = color, + animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing), + label = "gradientColor" + ).value + } + + Box( + modifier = Modifier + .fillMaxSize() + .alpha(backgroundAlpha) + .background( + Brush.verticalGradient( + colors = if (animatedGradientColors.isNotEmpty()) animatedGradientColors else gradientColors + ) + ) + ) + } + + // Animated dark overlay + AnimatedVisibility( + visible = overlayAlpha > 0f, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(300)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = overlayAlpha)) + ) + } + + // Additional overlay for lyrics + if (playerBackground != PlayerBackgroundStyle.DEFAULT && showLyrics) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Color.Black.copy( + alpha = animateFloatAsState( + targetValue = if (state.isExpanded) 0.4f else 0f, + animationSpec = tween(durationMillis = 500), + label = "lyricsOverlay" + ).value + ) + ) + ) + } + } + when (LocalConfiguration.current.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + Row( + modifier = + Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(top = queueSheetState.collapsedBound) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f), + ) { + val screenWidth = LocalConfiguration.current.screenWidthDp + val thumbnailSize = (screenWidth * 0.4).dp + + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Thumbnail( + sliderPositionProvider = { sliderPosition }, + onOpenFullscreenLyrics = onOpenFullscreenLyrics, + modifier = Modifier.size(thumbnailSize) + ) + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .weight(1f) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), + ) { + Spacer(Modifier.weight(1f)) + + mediaMetadata?.let { + controlsContent(it) + } + + Spacer(Modifier.weight(1f)) + } + } + } + + else -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(bottom = queueSheetState.collapsedBound), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) + ) { + Thumbnail( + sliderPositionProvider = { sliderPosition }, + onOpenFullscreenLyrics = onOpenFullscreenLyrics, + ) + } + } + + mediaMetadata?.let { + controlsContent(it) + } + + Spacer(Modifier.height(30.dp)) + } + } + } + + Queue( + state = queueSheetState, + playerBottomSheetState = state, + navController = navController, + backgroundColor = + if (useBlackBackground) { + Color.Black + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + onBackgroundColor = onBackgroundColor, + textBackgroundColor = TextBackgroundColor, + ) + } +} + +@Composable +fun ConcentricWaveEffect( + isPlaying: Boolean, + colors: List, + modifier: Modifier = Modifier +) { + if (colors.isEmpty()) return + + val infiniteTransition = rememberInfiniteTransition(label = "wave") + + // Slower animation (8 seconds instead of 5) + val animatedProgress by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 8000, // ← slower speed + easing = LinearEasing + ), + repeatMode = RepeatMode.Restart + ), + label = "waveProgress" + ) + + Canvas(modifier = modifier) { + + val circleCount = 24 // ← increased from 14 to 24 + val maxRadius = size.minDimension / 2f + + repeat(circleCount) { index -> + + // 🚫 If paused → freeze animation + val progress = if (isPlaying) { + (animatedProgress + index / circleCount.toFloat()) % 1f + } else { + 0f + } + + val radius = maxRadius * progress + + drawCircle( + color = colors[index % colors.size] + .copy(alpha = if (isPlaying) 1f - progress else 0.15f), + radius = radius, + center = center, + style = Stroke(width = 2.dp.toPx()) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/player/Queue.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/player/Queue.kt new file mode 100644 index 00000000..4da85417 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/player/Queue.kt @@ -0,0 +1,886 @@ +package com.darkxvenom.airbeats.ui.player + +import android.annotation.SuppressLint +import android.text.format.Formatter +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.add +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.sizeIn +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +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.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.constants.QueueEditLockKey +import com.darkxvenom.airbeats.constants.ShowLyricsKey +import com.darkxvenom.airbeats.extensions.metadata +import com.darkxvenom.airbeats.extensions.move +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.extensions.toggleRepeatMode +import com.darkxvenom.airbeats.models.MediaMetadata +import com.darkxvenom.airbeats.ui.component.BottomSheet +import com.darkxvenom.airbeats.ui.component.BottomSheetState +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.MediaMetadataListItem +import com.darkxvenom.airbeats.ui.menu.PlayerMenu +import com.darkxvenom.airbeats.ui.menu.SelectionMediaMetadataMenu +import com.darkxvenom.airbeats.utils.makeTimeString +import com.darkxvenom.airbeats.utils.rememberPreference +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + + +@SuppressLint("UnrememberedMutableState", "StringFormatInvalid") +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Queue( + state: BottomSheetState, + playerBottomSheetState: BottomSheetState, + navController: NavController, + modifier: Modifier = Modifier, + backgroundColor: Color, + onBackgroundColor: Color, + textBackgroundColor: Color, +) { + val context = LocalContext.current + val haptic = LocalHapticFeedback.current + val clipboardManager = LocalClipboardManager.current + val menuState = LocalMenuState.current + + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val repeatMode by playerConnection.repeatMode.collectAsState() + + val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val currentFormat by playerConnection.currentFormat.collectAsState(initial = null) + + val selectedSongs: MutableList = mutableStateListOf() + val selectedItems: MutableList = mutableStateListOf() + var selection by remember { + mutableStateOf(false) + } + + var showDetailsDialog by rememberSaveable { + mutableStateOf(false) + } + + var locked by rememberPreference(QueueEditLockKey, defaultValue = true) + + var showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) + + val snackbarHostState = remember { SnackbarHostState() } + var dismissJob: Job? by remember { mutableStateOf(null) } + + var showSleepTimerDialog by remember { mutableStateOf(false) } + var sleepTimerValue by remember { mutableStateOf(30f) } + val sleepTimerEnabled = remember( + playerConnection.service.sleepTimer.triggerTime, + playerConnection.service.sleepTimer.pauseWhenSongEnd + ) { + playerConnection.service.sleepTimer.isActive + } + var sleepTimerTimeLeft by remember { mutableStateOf(0L) } + + LaunchedEffect(sleepTimerEnabled) { + if (sleepTimerEnabled) { + while (isActive) { + sleepTimerTimeLeft = if (playerConnection.service.sleepTimer.pauseWhenSongEnd) { + playerConnection.player.duration - playerConnection.player.currentPosition + } else { + playerConnection.service.sleepTimer.triggerTime - System.currentTimeMillis() + } + delay(1000L) + } + } + } + + if (showDetailsDialog) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { showDetailsDialog = false }, + icon = { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + ) + }, + confirmButton = { + TextButton( + onClick = { showDetailsDialog = false }, + ) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { + Column( + modifier = + Modifier + .sizeIn(minWidth = 280.dp, maxWidth = 560.dp) + .verticalScroll(rememberScrollState()), + ) { + listOf( + stringResource(R.string.song_title) to mediaMetadata?.title, + stringResource(R.string.song_artists) to mediaMetadata?.artists?.joinToString { it.name }, + stringResource(R.string.media_id) to mediaMetadata?.id, + "Itag" to currentFormat?.itag?.toString(), + stringResource(R.string.mime_type) to currentFormat?.mimeType, + stringResource(R.string.codecs) to currentFormat?.codecs, + stringResource(R.string.bitrate) to currentFormat?.bitrate?.let { "${it / 1000} Kbps" }, + stringResource(R.string.sample_rate) to currentFormat?.sampleRate?.let { "$it Hz" }, + stringResource(R.string.loudness) to currentFormat?.loudnessDb?.let { "$it dB" }, + stringResource(R.string.volume) to "${(playerConnection.player.volume * 100).toInt()}%", + stringResource(R.string.file_size) to + currentFormat?.contentLength?.let { + Formatter.formatShortFileSize( + context, + it + ) + }, + ).forEach { (label, text) -> + val displayText = text ?: stringResource(R.string.unknown) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = displayText, + style = MaterialTheme.typography.titleMedium, + modifier = + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { + clipboardManager.setText(AnnotatedString(displayText)) + Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT) + .show() + }, + ), + ) + Spacer(Modifier.height(8.dp)) + } + } + }, + ) + } + + BottomSheet( + state = state, + background = { + Box( + Modifier + .fillMaxSize() + .background(backgroundColor) + ) + }, + modifier = modifier, + collapsedContent = { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), + ), + ) { + IconButton(onClick = { state.expandSoft() }) { + Icon( + painter = painterResource(R.drawable.expand_less), + tint = onBackgroundColor, + contentDescription = null, + ) + } + } + }, + ) { + val queueTitle by playerConnection.queueTitle.collectAsState() + val queueWindows by playerConnection.queueWindows.collectAsState() + val automix by playerConnection.service.automixItems.collectAsState() + val mutableQueueWindows = remember { mutableStateListOf() } + val queueLength = + remember(queueWindows) { + queueWindows.sumOf { it.mediaItem.metadata!!.duration } + } + + val coroutineScope = rememberCoroutineScope() + + val headerItems = 1 + val lazyListState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState( + lazyListState = lazyListState, + onMove = { from, to -> + if (to.index >= headerItems && from.index >= headerItems) { + mutableQueueWindows.move( + from.index - headerItems, + to.index - headerItems, + ) + + // Mover la lógica de onDragEnd aquí + val safeFrom = (from.index - headerItems).coerceIn(0, mutableQueueWindows.lastIndex) + val safeTo = (to.index - headerItems).coerceIn(0, mutableQueueWindows.lastIndex) + val toAdjusted = if (safeTo == 0) 1 else safeTo + + if (!playerConnection.player.shuffleModeEnabled) { + playerConnection.player.moveMediaItem(safeFrom, safeTo) + } else { + playerConnection.player.setShuffleOrder( + DefaultShuffleOrder( + queueWindows + .map { it.firstPeriodIndex } + .toMutableList() + .move(safeFrom, safeTo) + .toIntArray(), + System.currentTimeMillis(), + ), + ) + } + } + } + ) + + LaunchedEffect(queueWindows) { + mutableQueueWindows.apply { + clear() + addAll(queueWindows) + } + } + + LaunchedEffect(mutableQueueWindows) { + if (currentWindowIndex != -1) { + lazyListState.scrollToItem(currentWindowIndex) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(backgroundColor), + ) { + LazyColumn( + state = lazyListState, + contentPadding = + WindowInsets.systemBars + .add( + WindowInsets( + top = ListItemHeight + 8.dp, + bottom = ListItemHeight + 8.dp, + ), + ).asPaddingValues(), + modifier = + Modifier + .background(backgroundColor) + .nestedScroll(state.preUpPostDownNestedScrollConnection), + ) { + item { + Spacer( + modifier = + Modifier + .animateContentSize() + .height(if (selection) 48.dp else 0.dp), + ) + } + + itemsIndexed( + items = mutableQueueWindows, + key = { _, item -> item.uid.hashCode() }, + ) { index, window -> + ReorderableItem( + state = reorderableState, // Cambiar reorderableState por state + key = window.uid.hashCode(), + ) { + val currentItem by rememberUpdatedState(window) + val dismissBoxState = + rememberSwipeToDismissBoxState( + positionalThreshold = { totalDistance -> + totalDistance + }, + confirmValueChange = { dismissValue -> + if (dismissValue == SwipeToDismissBoxValue.StartToEnd || + dismissValue == SwipeToDismissBoxValue.EndToStart + ) { + playerConnection.player.removeMediaItem(currentItem.firstPeriodIndex) + dismissJob?.cancel() + dismissJob = + coroutineScope.launch { + val snackbarResult = + snackbarHostState.showSnackbar( + message = + context.getString( + R.string.removed_song_from_playlist, + currentItem.mediaItem.metadata?.title, + ), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Short, + ) + if (snackbarResult == SnackbarResult.ActionPerformed) { + playerConnection.player.addMediaItem(currentItem.mediaItem) + playerConnection.player.moveMediaItem( + mutableQueueWindows.size, + currentItem.firstPeriodIndex, + ) + } + } + } + true + }, + ) + + val content: @Composable () -> Unit = { + Row( + horizontalArrangement = Arrangement.Center, + ) { + MediaMetadataListItem( + mediaMetadata = window.mediaItem.metadata!!, + isSelected = selection && window.mediaItem.metadata!! in selectedSongs, + isActive = index == currentWindowIndex, + isPlaying = isPlaying, + trailingContent = { + if (!locked) { + IconButton( + onClick = { }, + modifier = + Modifier.draggableHandle(), + ) { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = null, + ) + } + } + IconButton( + onClick = { + menuState.show { + SelectionMediaMetadataMenu( + songSelection = selectedSongs, + onDismiss = menuState::dismiss, + clearAction = { + selectedSongs.clear() + selectedItems.clear() + }, + currentItems = selectedItems, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + tint = LocalContentColor.current, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (selection) { + if (window.mediaItem.metadata!! in selectedSongs) { + selectedSongs.remove(window.mediaItem.metadata!!) + selectedItems.remove(currentItem) + } else { + selectedSongs.add(window.mediaItem.metadata!!) + selectedItems.add(currentItem) + } + } else { + if (index == currentWindowIndex) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.player.seekToDefaultPosition( + window.firstPeriodIndex, + ) + playerConnection.player.playWhenReady = + true + } + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + } + selectedSongs.clear() // Clear all selections + selectedSongs.add(window.mediaItem.metadata!!) // Select current item + }, + ), + ) + } + } + + if (locked) { + content() + } else { + SwipeToDismissBox( + state = dismissBoxState, + backgroundContent = {}, + ) { + content() + } + } + } + } + + if (automix.isNotEmpty()) { + item { + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp), + ) + + ItemWithGlowingIcon() + } + + itemsIndexed( + items = automix, + key = { _, it -> it.mediaId }, + ) { index, item -> + Row( + horizontalArrangement = Arrangement.Center, + ) { + MediaMetadataListItem( + mediaMetadata = item.metadata!!, + trailingContent = { + IconButton( + onClick = { + playerConnection.service.playNextAutomix( + item, + index, + ) + }, + ) { + Icon( + painter = painterResource(R.drawable.playlist_play), + contentDescription = null, + ) + } + IconButton( + onClick = { + playerConnection.service.addToQueueAutomix( + item, + index, + ) + }, + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = {}, + onLongClick = { + menuState.show { + PlayerMenu( + mediaMetadata = item.metadata!!, + navController = navController, + playerBottomSheetState = playerBottomSheetState, + isQueueTrigger = true, + onShowDetailsDialog = { + showDetailsDialog = true + }, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } + } + } + } + + Column( + modifier = + Modifier + .background( + MaterialTheme.colorScheme + .secondaryContainer + .copy(alpha = 0.90f), + ) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + ), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .height(ListItemHeight) + .padding(horizontal = 12.dp), + ) { + Text( + text = queueTitle.orEmpty(), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + AnimatedVisibility( + visible = !selection, + enter = fadeIn() + slideInVertically { it }, + exit = fadeOut() + slideOutVertically { it }, + ) { + Row { + IconButton( + onClick = { locked = !locked }, + modifier = Modifier.padding(horizontal = 6.dp), + ) { + Icon( + painter = painterResource(if (locked) R.drawable.lock else R.drawable.lock_open), + contentDescription = null, + ) + } + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.End, + ) { + Text( + text = pluralStringResource( + R.plurals.n_song, + queueWindows.size, + queueWindows.size + ), + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + text = makeTimeString(queueLength * 1000L), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + AnimatedVisibility( + visible = selection, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Row( + modifier = + Modifier + .height(48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val count = selectedSongs.size + IconButton( + onClick = { + selection = false + }, + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + Text( + text = stringResource(R.string.elements_selected, count), + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + if (count == mutableQueueWindows.size) { + selectedSongs.clear() + selectedItems.clear() + } else { + queueWindows + .filter { it.mediaItem.metadata!! !in selectedSongs } + .forEach { + selectedSongs.add(it.mediaItem.metadata!!) + selectedItems.add(it) + } + } + }, + ) { + Icon( + painter = + painterResource( + if (count == mutableQueueWindows.size) { + R.drawable.deselect + } else { + R.drawable.select_all + }, + ), + contentDescription = null, + ) + } + + IconButton( + onClick = { + menuState.show { + SelectionMediaMetadataMenu( + songSelection = selectedSongs, + onDismiss = menuState::dismiss, + clearAction = { + selectedSongs.clear() + selectedItems.clear() + }, + currentItems = selectedItems, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + tint = LocalContentColor.current, + ) + } + } + } + } + + val shuffleModeEnabled by playerConnection.shuffleModeEnabled.collectAsState() + + Box( + modifier = + Modifier + .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.8f)) + .fillMaxWidth() + .height( + ListItemHeight + + WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding(), + ) + .align(Alignment.BottomCenter) + .clickable { + state.collapseSoft() + } + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), + ) + .padding(12.dp), + ) { + IconButton( + modifier = Modifier.align(Alignment.CenterStart), + onClick = { + coroutineScope.launch { + lazyListState.animateScrollToItem( + if (playerConnection.player.shuffleModeEnabled) playerConnection.player.currentMediaItemIndex else 0, + ) + }.invokeOnCompletion { + playerConnection.player.shuffleModeEnabled = + !playerConnection.player.shuffleModeEnabled + } + }, + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.alpha(if (shuffleModeEnabled) 1f else 0.5f), + ) + } + + Icon( + painter = painterResource(R.drawable.expand_more), + contentDescription = null, + modifier = Modifier.align(Alignment.Center), + ) + + IconButton( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = playerConnection.player::toggleRepeatMode, + ) { + Icon( + painter = + painterResource( + when (repeatMode) { + Player.REPEAT_MODE_OFF, Player.REPEAT_MODE_ALL -> R.drawable.repeat + Player.REPEAT_MODE_ONE -> R.drawable.repeat_one + else -> throw IllegalStateException() + }, + ), + contentDescription = null, + modifier = Modifier.alpha(if (repeatMode == Player.REPEAT_MODE_OFF) 0.5f else 1f), + ) + } + } + + SnackbarHost( + hostState = snackbarHostState, + modifier = + Modifier + .padding( + bottom = + ListItemHeight + + WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding(), + ) + .align(Alignment.BottomCenter), + ) + } +} + +@SuppressLint("SuspiciousIndentation") +@Composable +fun ItemWithGlowingIcon() { + val infiniteTransition = rememberInfiniteTransition(label = "") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), label = "" + ) + + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 0.7f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), label = "" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(28.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Box { + Icon( + painter = painterResource(R.drawable.ia_icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(30.dp) + .scale(scale) + .alpha(alpha) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(R.string.similar_content), + fontSize = 16.sp, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily.SansSerif, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/player/Thumbnail.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/player/Thumbnail.kt new file mode 100644 index 00000000..0e677805 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/player/Thumbnail.kt @@ -0,0 +1,422 @@ +package com.darkxvenom.airbeats.ui.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.background +import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +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.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.mutableLongStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEach +import androidx.media3.common.C +import androidx.media3.common.Player +import coil.compose.AsyncImage +import coil.request.CachePolicy +import coil.request.ImageRequest +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.PlayerBackgroundStyle +import com.darkxvenom.airbeats.constants.PlayerBackgroundStyleKey +import com.darkxvenom.airbeats.constants.PlayerHorizontalPadding +import com.darkxvenom.airbeats.constants.SwipeThumbnailKey +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import kotlinx.coroutines.delay +import kotlin.math.abs + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Thumbnail( + sliderPositionProvider: () -> Long?, + onOpenFullscreenLyrics: () -> Unit, + modifier: Modifier = Modifier, + isPlayerExpanded: Boolean = true, + +) { + + + val playerConnection = LocalPlayerConnection.current ?: return + val context = LocalContext.current + val currentView = LocalView.current + val coroutineScope = rememberCoroutineScope() + + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val error by playerConnection.error.collectAsState() + val queueTitle by playerConnection.queueTitle.collectAsState() + + val swipeThumbnail by rememberPreference(SwipeThumbnailKey, true) + val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() + val canSkipNext by playerConnection.canSkipNext.collectAsState() + + val playerBackground by rememberEnumPreference( + key = PlayerBackgroundStyleKey, + defaultValue = PlayerBackgroundStyle.DEFAULT + ) + + val textBackgroundColor = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.onBackground + PlayerBackgroundStyle.BLUR -> Color.White + PlayerBackgroundStyle.GRADIENT -> Color.White + } + + val thumbnailLazyGridState = rememberLazyGridState() + + val timeline = playerConnection.player.currentTimeline + val currentIndex = playerConnection.player.currentMediaItemIndex + val shuffleModeEnabled = playerConnection.player.shuffleModeEnabled + val previousMediaMetadata = if (swipeThumbnail && !timeline.isEmpty) { + val previousIndex = timeline.getPreviousWindowIndex( + currentIndex, + Player.REPEAT_MODE_OFF, + shuffleModeEnabled + ) + if (previousIndex != C.INDEX_UNSET) { + try { + playerConnection.player.getMediaItemAt(previousIndex) + } catch (e: Exception) { null } + } else null + } else null + + val nextMediaMetadata = if (swipeThumbnail && !timeline.isEmpty) { + val nextIndex = timeline.getNextWindowIndex( + currentIndex, + Player.REPEAT_MODE_OFF, + shuffleModeEnabled + ) + if (nextIndex != C.INDEX_UNSET) { + try { + playerConnection.player.getMediaItemAt(nextIndex) + } catch (e: Exception) { null } + } else null + } else null + + val currentMediaItem = try { + playerConnection.player.currentMediaItem + } catch (e: Exception) { null } + + val mediaItems = listOfNotNull(previousMediaMetadata, currentMediaItem, nextMediaMetadata) + val currentMediaIndex = mediaItems.indexOf(currentMediaItem) + + val horizontalLazyGridItemWidthFactor = 1f + val thumbnailSnapLayoutInfoProvider = remember(thumbnailLazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = thumbnailLazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) + }, + velocityThreshold = 500f + ) + } + + val currentItem by remember { derivedStateOf { thumbnailLazyGridState.firstVisibleItemIndex } } + val itemScrollOffset by remember { derivedStateOf { thumbnailLazyGridState.firstVisibleItemScrollOffset } } + + LaunchedEffect(itemScrollOffset) { + if (!thumbnailLazyGridState.isScrollInProgress || !swipeThumbnail || itemScrollOffset != 0 || currentMediaIndex < 0) return@LaunchedEffect + + if (currentItem > currentMediaIndex && canSkipNext) { + playerConnection.player.seekToNext() + } else if (currentItem < currentMediaIndex && canSkipPrevious) { + playerConnection.player.seekToPreviousMediaItem() + } + } + + LaunchedEffect(mediaMetadata, canSkipPrevious, canSkipNext) { + val index = maxOf(0, currentMediaIndex) + if (index >= 0 && index < mediaItems.size) { + try { + thumbnailLazyGridState.animateScrollToItem(index) + } catch (e: Exception) { + thumbnailLazyGridState.scrollToItem(index) + } + } + } + + LaunchedEffect(playerConnection.player.currentMediaItemIndex) { + val index = mediaItems.indexOf(currentMediaItem) + if (index >= 0 && index != currentItem) { + thumbnailLazyGridState.scrollToItem(index) + } + } + + var showSeekEffect by remember { mutableStateOf(false) } + var seekDirection by remember { mutableStateOf("") } + val layoutDirection = LocalLayoutDirection.current + + Box(modifier = modifier) { + AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .padding(32.dp) + .align(Alignment.Center), + ) { + error?.let { playbackError -> + PlaybackError( + error = playbackError, + retry = playerConnection.player::prepare, + ) + } + } + + AnimatedVisibility( + visible = error == null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .fillMaxSize() + .statusBarsPadding(), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp) + ) { + Text( + text = stringResource(R.string.playing_from), + style = MaterialTheme.typography.titleMedium, + color = textBackgroundColor + ) + val playingFrom = queueTitle ?: mediaMetadata?.album?.title + if (!playingFrom.isNullOrBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = playingFrom, + style = MaterialTheme.typography.titleMedium, + color = textBackgroundColor.copy(alpha = 0.8f), + maxLines = 1, + modifier = Modifier.basicMarquee() + ) + } + } + + BoxWithConstraints( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor + val containerMaxWidth = maxWidth + + LazyHorizontalGrid( + state = thumbnailLazyGridState, + rows = GridCells.Fixed(1), + flingBehavior = rememberSnapFlingBehavior(thumbnailSnapLayoutInfoProvider), + userScrollEnabled = swipeThumbnail && isPlayerExpanded, + modifier = Modifier.fillMaxSize() + ) { + items( + items = mediaItems, + key = { item -> + item.mediaId.ifEmpty { "unknown_${item.hashCode()}" } + } + ) { item -> + var skipMultiplier by remember { mutableIntStateOf(1) } + var lastTapTime by remember { mutableLongStateOf(0L) } + + Box( + modifier = Modifier + .width(horizontalLazyGridItemWidth) + .fillMaxSize() +// .padding(horizontal = PlayerHorizontalPadding) + .pointerInput(Unit) { + detectTapGestures( + onTap = { + onOpenFullscreenLyrics() + }, + onDoubleTap = { offset -> + if (offset.x < size.width / 2) { + playerConnection.player.seekBack() + } else { + playerConnection.player.seekForward() + } + }, + ) + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(containerMaxWidth - (PlayerHorizontalPadding * 2)) + .aspectRatio(1f), + contentAlignment = Alignment.Center + ) { + + // 🌊 Waves Behind + ConcentricWaveEffect( + isPlaying = playerConnection.isPlaying.collectAsState().value, + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.tertiary + ), + modifier = Modifier.fillMaxSize() + ) + + // 🎵 Circular Album Art + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(item.mediaMetadata.artworkUri?.toString()) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize(0.85f) + .clip(CircleShape) + ) + } + } + } + } + } + } + } + + LaunchedEffect(showSeekEffect) { + if (showSeekEffect) { + delay(1000) + showSeekEffect = false + } + } + + AnimatedVisibility( + visible = showSeekEffect, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.Center) + ) { + Text( + text = seekDirection, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier + .background(Color.Black.copy(alpha = 0.7f), RoundedCornerShape(8.dp)) + .padding(8.dp) + ) + } + } +} + +@ExperimentalFoundationApi +fun SnapLayoutInfoProvider( + lazyGridState: LazyGridState, + positionInLayout: (layoutSize: Float, itemSize: Float) -> Float = { layoutSize, itemSize -> + (layoutSize / 2f - itemSize / 2f) + }, + velocityThreshold: Float = 1000f, +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { + private val layoutInfo: LazyGridLayoutInfo + get() = lazyGridState.layoutInfo + + override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float = 0f + override fun calculateSnapOffset(velocity: Float): Float { + val bounds = calculateSnappingOffsetBounds() + + if (abs(velocity) < velocityThreshold) { + if (abs(bounds.start) < abs(bounds.endInclusive)) + return bounds.start + + return bounds.endInclusive + } + + return when { + velocity < 0 -> bounds.start + velocity > 0 -> bounds.endInclusive + else -> 0f + } + } + + fun calculateSnappingOffsetBounds(): ClosedFloatingPointRange { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) + + if (offset <= 0 && offset > lowerBoundOffset) { + lowerBoundOffset = offset + } + + if (offset >= 0 && offset < upperBoundOffset) { + upperBoundOffset = offset + } + } + + return lowerBoundOffset.rangeTo(upperBoundOffset) + } +} + +fun calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyGridLayoutInfo, + item: LazyGridItemInfo, + positionInLayout: (layoutSize: Float, itemSize: Float) -> Float, +): Float { + val containerSize = + layoutInfo.singleAxisViewportSize - layoutInfo.beforeContentPadding - layoutInfo.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 \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/AccountScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/AccountScreen.kt new file mode 100644 index 00000000..73a1c296 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/AccountScreen.kt @@ -0,0 +1,163 @@ +package com.darkxvenom.airbeats.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.YouTubeGridItem +import com.darkxvenom.airbeats.ui.component.shimmer.GridItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.menu.YouTubeAlbumMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeArtistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubePlaylistMenu +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.viewmodels.AccountViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun AccountScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: AccountViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + + val coroutineScope = rememberCoroutineScope() + + val playlists by viewModel.playlists.collectAsState() + + val albums by viewModel.albums.collectAsState() + + val artists by viewModel.artists.collectAsState() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + items( + items = playlists.orEmpty(), + key = { it.id }, + ) { item -> + YouTubeGridItem( + item = item, + fillMaxWidth = true, + modifier = + Modifier + .combinedClickable( + onClick = { + navController.navigate("online_playlist/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubePlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }, + ), + ) + } + + items( + items = albums.orEmpty(), + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + fillMaxWidth = true, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + + items( + items = artists.orEmpty(), + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + fillMaxWidth = true, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("artist/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + + if (playlists == null) { + items(8) { + ShimmerHost { + GridItemPlaceHolder(fillMaxWidth = true) + } + } + } + } + + TopAppBar( + title = { Text(stringResource(R.string.account)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/AlbumScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/AlbumScreen.kt new file mode 100644 index 00000000..78ba4bf3 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/AlbumScreen.kt @@ -0,0 +1,714 @@ +package com.darkxvenom.airbeats.ui.screens + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.os.Environment +import android.provider.MediaStore +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +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.Spacer +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AlbumThumbnailSize +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.LocalAlbumRadio +import com.darkxvenom.airbeats.ui.component.AutoResizeText +import com.darkxvenom.airbeats.ui.component.FontSizeRange +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.NavigationTitle +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.YouTubeGridItem +import com.darkxvenom.airbeats.ui.component.shimmer.ButtonPlaceholder +import com.darkxvenom.airbeats.ui.component.shimmer.ListItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.component.shimmer.TextPlaceholder +import com.darkxvenom.airbeats.ui.menu.AlbumMenu +import com.darkxvenom.airbeats.ui.menu.SelectionSongMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeAlbumMenu +import com.darkxvenom.airbeats.ui.utils.ItemWrapper +import com.darkxvenom.airbeats.viewmodels.AlbumViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.io.IOException + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AlbumScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: AlbumViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val haptic = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val playerConnection = LocalPlayerConnection.current ?: return + + val scope = rememberCoroutineScope() + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val playlistId by viewModel.playlistId.collectAsState() + val albumWithSongs by viewModel.albumWithSongs.collectAsState() + val otherVersions by viewModel.otherVersions.collectAsState() + + val wrappedSongs = albumWithSongs?.songs?.map { item -> ItemWrapper(item) }?.toMutableList() + var selection by remember { + mutableStateOf(false) + } + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(albumWithSongs) { + val songs = albumWithSongs?.songs?.map { it.id } + if (songs.isNullOrEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) { + Download.STATE_COMPLETED + } else if (songs.all { + downloads[it]?.state == Download.STATE_QUEUED || + downloads[it]?.state == Download.STATE_DOWNLOADING || + downloads[it]?.state == Download.STATE_COMPLETED + } + ) { + Download.STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + val albumWithSongs = albumWithSongs + if (albumWithSongs != null && albumWithSongs.songs.isNotEmpty()) { + item { + Column( + modifier = Modifier.padding(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) { + // Mostrar la imagen del álbum + AsyncImage( + model = albumWithSongs.album.thumbnailUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // Botón de descarga superpuesto + IconButton( + onClick = { + // Lanza una coroutine para guardar la imagen + CoroutineScope(Dispatchers.IO).launch { + albumWithSongs.album.thumbnailUrl?.let { + saveAlbumImageToGallery( + context, + it, + albumWithSongs.album.title + ) + } + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) // Ubicar el botón en la esquina inferior derecha + .padding(8.dp) // Margen desde el borde + .size(40.dp) // Hacerlo más pequeño + .clip(RoundedCornerShape(24.dp)) // Bordes redondeados + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) // Fondo semitransparente + ) + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = "Guardar imagen en galería", + tint = MaterialTheme.colorScheme.onSecondaryContainer // Color del ícono + ) + } + } + + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = albumWithSongs.album.title, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp), + ) + + val annotatedString = + buildAnnotatedString { + withStyle( + style = + MaterialTheme.typography.titleMedium + .copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground, + ).toSpanStyle(), + ) { + albumWithSongs.artists.fastForEachIndexed { index, artist -> + pushStringAnnotation(artist.id, artist.name) + append(artist.name) + pop() + if (index != albumWithSongs.artists.lastIndex) { + append(", ") + } + } + } + } + ClickableText(annotatedString) { offset -> + annotatedString.getStringAnnotations(offset, offset).firstOrNull() + ?.let { range -> + navController.navigate("artist/${range.tag}") + } + } + + if (albumWithSongs.album.year != null) { + Text( + text = albumWithSongs.album.year.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + ) + } + + Row { + IconButton( + onClick = { + database.query { + update(albumWithSongs.album.toggleLike()) + } + }, + ) { + Icon( + painter = + painterResource( + if (albumWithSongs.album.bookmarkedAt != + null + ) { + R.drawable.favorite + } else { + R.drawable.favorite_border + }, + ), + contentDescription = null, + tint = + if (albumWithSongs.album.bookmarkedAt != + null + ) { + MaterialTheme.colorScheme.error + } else { + LocalContentColor.current + }, + ) + } + + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton( + onClick = { + albumWithSongs.songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + ) + } + } + + Download.STATE_DOWNLOADING -> { + IconButton( + onClick = { + albumWithSongs.songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false, + ) + } + }, + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + ) + } + } + + else -> { + IconButton( + onClick = { + albumWithSongs.songs.forEach { song -> + val downloadRequest = + DownloadRequest + .Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + ) + } + } + } + + IconButton( + onClick = { + menuState.show { + AlbumMenu( + originalAlbum = Album( + albumWithSongs.album, + albumWithSongs.artists + ), + navController = navController, + onDismiss = menuState::dismiss, + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.service.getAutomix(playlistId) + playerConnection.playQueue( + LocalAlbumRadio(albumWithSongs), + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.play), + ) + } + + OutlinedButton( + onClick = { + playerConnection.service.getAutomix(playlistId) + playerConnection.playQueue( + LocalAlbumRadio(albumWithSongs.copy(songs = albumWithSongs.songs.shuffled())), + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + + if (wrappedSongs != null) { + itemsIndexed( + items = wrappedSongs, + key = { _, song -> song.item.id }, + ) { index, songWrapper -> + SongListItem( + song = songWrapper.item, + albumIndex = index + 1, + isActive = songWrapper.item.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + isSelected = songWrapper.isSelected && selection, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (songWrapper.item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.service.getAutomix(playlistId) + playerConnection.playQueue( + LocalAlbumRadio( + albumWithSongs, + startIndex = index + ), + ) + } + } else { + songWrapper.isSelected = !songWrapper.isSelected + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + } + wrappedSongs.forEach { + it.isSelected = false + } // Clear previous selections + songWrapper.isSelected = true // Select the current item + }, + ), + ) + } + } + + if (otherVersions.isNotEmpty()) { + item { + NavigationTitle( + title = stringResource(R.string.other_versions), + ) + } + item { + LazyRow { + items( + items = otherVersions, + key = { it.id }, + ) { item -> + YouTubeGridItem( + item = item, + isActive = mediaMetadata?.album?.id == item.id, + isPlaying = isPlaying, + coroutineScope = scope, + modifier = + Modifier + .combinedClickable( + onClick = { navController.navigate("album/${item.id}") }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } + } + } + } else { + item { + ShimmerHost { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer( + modifier = + Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(MaterialTheme.colorScheme.onSurface), + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } + + Spacer(Modifier.padding(8.dp)) + + Row { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + + TopAppBar( + title = { + if (selection) { + val count = wrappedSongs?.count { it.isSelected } ?: 0 + Text( + text = pluralStringResource(R.plurals.n_song, count, count), + style = MaterialTheme.typography.titleLarge + ) + } else { + Text( + text = albumWithSongs?.album?.title.orEmpty(), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + navigationIcon = { + IconButton( + onClick = { + if (selection) { + selection = false + } else { + navController.navigateUp() + } + }, + ) { + Icon( + painter = painterResource( + if (selection) R.drawable.close else R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (selection) { + val count = wrappedSongs?.count { it.isSelected } ?: 0 + IconButton( + onClick = { + if (count == wrappedSongs?.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs?.forEach { it.isSelected = true } + } + }, + ) { + Icon( + painter = painterResource( + if (count == wrappedSongs?.size) R.drawable.deselect else R.drawable.select_all + ), + contentDescription = null + ) + } + + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs?.filter { it.isSelected }!! + .map { it.item }, + onDismiss = menuState::dismiss, + clearAction = { selection = false } + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else { + } + } + ) +} + +suspend fun saveAlbumImageToGallery(context: Context, imageUrl: String, albumTitle: String) { + try { + // Cargar la imagen usando Coil + val request = ImageRequest.Builder(context) + .data(imageUrl) + .build() + + val drawable = context.imageLoader.execute(request).drawable + + if (drawable != null) { + val bitmap = drawable.toBitmap() + + // Usar el título del álbum para el nombre del archivo + val displayName = "${albumTitle.replace(" ", "_")}.png" + val mimeType = "image/png" // Tipo MIME para PNG + + + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, displayName) + put(MediaStore.Images.Media.MIME_TYPE, mimeType) + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) + } + + val contentResolver = context.contentResolver + val uri = contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ) + + uri?.let { + contentResolver.openOutputStream(it)?.use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + } + + // Mostrar un mensaje de éxito + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Save Picture Success", + Toast.LENGTH_SHORT + ).show() + } + } + } + } catch (e: IOException) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "X", + Toast.LENGTH_SHORT + ).show() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ExploreScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ExploreScreen.kt new file mode 100644 index 00000000..a52f4f5d --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/ExploreScreen.kt @@ -0,0 +1,219 @@ +package com.darkxvenom.airbeats.ui.screens + +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.only +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.Spacer +import androidx.compose.ui.graphics.luminance +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.ui.text.font.FontWeight +import coil.compose.AsyncImage +import androidx.compose.ui.layout.ContentScale +import com.darkxvenom.airbeats.LocalPlayerConnection +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.platform.LocalConfiguration +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.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.NavigationTitle +import com.darkxvenom.airbeats.ui.component.shimmer.ListItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.viewmodels.MoodAndGenresViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExploreScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: MoodAndGenresViewModel = hiltViewModel(), +) { + + val playerConnection = LocalPlayerConnection.current ?: return + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val localConfiguration = LocalConfiguration.current + val itemsPerRow = + if (localConfiguration.orientation == ORIENTATION_LANDSCAPE) 3 else 2 + + val moodAndGenresList by viewModel.moodAndGenres.collectAsState() + + Box(modifier = Modifier.fillMaxSize()) { + + // 🎵 SAME BLUR BACKGROUND AS HOME + val artworkUrl = mediaMetadata?.thumbnailUrl + + artworkUrl?.let { imageUrl -> + + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + + val isDarkTheme = + MaterialTheme.colorScheme.background.luminance() < 0.5f + + val overlayBrush = if (isDarkTheme) { + Brush.verticalGradient( + listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.85f) + ) + ) + } else { + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.25f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + MaterialTheme.colorScheme.background.copy(alpha = 0.85f) + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(overlayBrush) + ) + + } + + // 📜 CONTENT + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier + .windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Top) + ) + ) { + + // ── Header title ── + item { + Text( + text = stringResource(R.string.explore), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + ) + } + + if (moodAndGenresList == null) { + item { + ShimmerHost { + repeat(8) { + ListItemPlaceHolder() + } + } + } + } + + moodAndGenresList?.forEach { moodAndGenres -> + item { + + NavigationTitle( + title = moodAndGenres.title, + ) + + Column( + modifier = Modifier.padding(horizontal = 6.dp), + ) { + moodAndGenres.items.chunked(itemsPerRow).forEach { row -> + Row { + row.forEach { + MoodAndGenresButton( + title = it.title, + onClick = { + navController.navigate( + "youtube_browse/${it.endpoint.browseId}?params=${it.endpoint.params}" + ) + }, + modifier = Modifier + .weight(1f) + .padding(6.dp), + ) + } + + repeat(itemsPerRow - row.size) { + Spacer(Modifier.weight(1f)) + } + } + } + } + } + } + } + } +} + + +@Composable +fun MoodAndGenresButton( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = + modifier + .height(MoodAndGenresButtonHeight) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +val MoodAndGenresButtonHeight = 48.dp \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/HistoryScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/HistoryScreen.kt new file mode 100644 index 00000000..cbd6e36e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/HistoryScreen.kt @@ -0,0 +1,506 @@ +package com.darkxvenom.airbeats.ui.screens + +import androidx.activity.compose.BackHandler +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.PaddingValues +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.windowInsetsPadding +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.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +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.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.darkxvenom.airbeats.innertube.utils.parseCookieString +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.HistorySource +import com.darkxvenom.airbeats.constants.InnerTubeCookieKey +import com.darkxvenom.airbeats.db.entities.EventWithSong +import com.darkxvenom.airbeats.extensions.metadata +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.ChipsRow +import com.darkxvenom.airbeats.ui.component.HideOnScrollFAB +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.NavigationTitle +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.YouTubeListItem +import com.darkxvenom.airbeats.ui.menu.SelectionMediaMetadataMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeSongMenu +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.DateAgo +import com.darkxvenom.airbeats.viewmodels.HistoryViewModel +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun HistoryScreen( + navController: NavController, + viewModel: HistoryViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + var selection by remember { + mutableStateOf(false) + } + + var isSearching by rememberSaveable { mutableStateOf(false) } + var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + if (isSearching) { + BackHandler { + isSearching = false + query = TextFieldValue() + } + } else if (selection) { + BackHandler { + selection = false + } + } + + val historySource by viewModel.historySource.collectAsState() + val events by viewModel.events.collectAsState() + val historyPage by viewModel.historyPage + + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + fun dateAgoToString(dateAgo: DateAgo): String { + return when (dateAgo) { + DateAgo.Today -> context.getString(R.string.today) + DateAgo.Yesterday -> context.getString(R.string.yesterday) + DateAgo.ThisWeek -> context.getString(R.string.this_week) + DateAgo.LastWeek -> context.getString(R.string.last_week) + is DateAgo.Other -> dateAgo.date.format(DateTimeFormatter.ofPattern("yyyy/MM")) + } + } + + class WrappedHistoryItem(val item: EventWithSong) { + var isSelected by mutableStateOf(false) + } + + val filteredEvents = remember(events, query) { + if (query.text.isEmpty()) { + events + } else { + events.mapValues { (_, songs) -> + songs.filter { event -> + event.song.song.title.contains(query.text, ignoreCase = true) || + event.song.artists.any { + it.name.contains( + query.text, + ignoreCase = true + ) + } + } + }.filterValues { it.isNotEmpty() } + } + } + + val filteredRemoteContent = remember(historyPage, query) { + if (query.text.isEmpty()) { + historyPage?.sections + } else { + historyPage?.sections?.map { section -> + section.copy( + songs = section.songs.filter { song -> + song.title.contains(query.text, ignoreCase = true) || + song.artists.any { it.name.contains(query.text, ignoreCase = true) } + } + ) + }?.filter { it.songs.isNotEmpty() } + } + } + + val wrappedItems = remember(filteredEvents) { + filteredEvents.flatMap { it.value }.map { WrappedHistoryItem(it) } + }.toMutableStateList() + + val lazyListState = rememberLazyListState() + + Box(Modifier.fillMaxSize()) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + .asPaddingValues(), + modifier = Modifier.fillMaxSize() + ) { + // Add spacer to push content below the TopAppBar + item { + Spacer(modifier = Modifier.height(64.dp)) + } + + item { + ChipsRow( + chips = if (isLoggedIn) listOf( + HistorySource.LOCAL to stringResource(R.string.local_history), + HistorySource.REMOTE to stringResource(R.string.remote_history), + ) else { + listOf(HistorySource.LOCAL to stringResource(R.string.local_history)) + }, + currentValue = historySource, + onValueUpdate = { + viewModel.historySource.value = it + if (it == HistorySource.REMOTE) { + viewModel.fetchRemoteHistory() + } + } + ) + } + + if (historySource == HistorySource.REMOTE && isLoggedIn) { + filteredRemoteContent?.forEach { section -> + stickyHeader { + NavigationTitle( + title = section.title, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + ) + } + + items( + items = section.songs, + key = { "${section.title}_${it.id}" } + ) { song -> + YouTubeListItem( + item = song, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue.radio(song.toMediaMetadata()) + ) + } + }, + onLongClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItem() + ) + } + } + } else { + filteredEvents.forEach { (dateAgo, events) -> + stickyHeader { + NavigationTitle( + title = dateAgoToString(dateAgo), + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + ) + } + + itemsIndexed( + items = wrappedItems, + ) { index, wrappedItem -> + val event = wrappedItem.item + SongListItem( + song = event.song, + isActive = event.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + isSelected = wrappedItem.isSelected && selection, + + trailingContent = { + IconButton( + onClick = { + if (!selection) { + menuState.show { + SongMenu( + originalSong = event.song, + event = event.event, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (event.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = dateAgoToString(dateAgo), + items = wrappedItems.map { it.item.song.toMediaItem() }, + startIndex = index + ) + ) + } + } else { + wrappedItem.isSelected = !wrappedItem.isSelected + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + wrappedItems.forEach { it.isSelected = false } + wrappedItem.isSelected = true + } + } + ) + .animateItem() + ) + } + } + } + } + + HideOnScrollFAB( + visible = if (historySource == HistorySource.REMOTE) { + filteredRemoteContent?.any { it.songs.isNotEmpty() } == true + } else { + wrappedItems.isNotEmpty() + }, + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + if (historySource == HistorySource.REMOTE && historyPage != null) { + val songs = filteredRemoteContent?.flatMap { it.songs } ?: emptyList() + if (songs.isNotEmpty()) { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.history), + items = songs.map { it.toMediaItem() }.shuffled() + ) + ) + } + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.history), + items = wrappedItems.map { it.item.song.toMediaItem() }.shuffled() + ) + ) + } + } + ) + } + + TopAppBar( + title = { + if (selection) { + val count = wrappedItems.count { it.isSelected } + Text( + text = pluralStringResource(R.plurals.n_song, count, count), + style = MaterialTheme.typography.titleLarge + ) + } else if (isSearching) { + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + } else { + Text(stringResource(R.string.history)) + } + }, + navigationIcon = { + IconButton( + onClick = { + when { + isSearching -> { + isSearching = false + query = TextFieldValue() + } + + selection -> { + selection = false + } + + else -> { + navController.navigateUp() + } + } + }, + onLongClick = { + if (!isSearching && !selection) { + navController.backToMain() + } + } + ) { + Icon( + painter = painterResource( + if (selection) R.drawable.close else R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (selection) { + val count = wrappedItems.count { it.isSelected } + IconButton( + onClick = { + if (count == wrappedItems.size) { + wrappedItems.forEach { it.isSelected = false } + } else { + wrappedItems.forEach { it.isSelected = true } + } + } + ) { + Icon( + painter = painterResource( + if (count == wrappedItems.size) R.drawable.deselect else R.drawable.select_all + ), + contentDescription = null + ) + } + IconButton( + onClick = { + menuState.show { + SelectionMediaMetadataMenu( + songSelection = wrappedItems + .filter { it.isSelected } + .map { it.item.song.toMediaItem().metadata!! }, + onDismiss = menuState::dismiss, + clearAction = { selection = false }, + currentItems = emptyList() + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else if (!isSearching) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/HomeScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/HomeScreen.kt new file mode 100644 index 00000000..60a291be --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/HomeScreen.kt @@ -0,0 +1,1271 @@ +package com.darkxvenom.airbeats.ui.screens + +import android.annotation.SuppressLint +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.animateFloat +import androidx.compose.ui.draw.scale +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.foundation.Canvas +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.geometry.Offset +import kotlin.math.cos +import kotlin.math.sin +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.RepeatMode +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Spacer +import kotlinx.coroutines.withContext +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.withContext +import com.darkxvenom.airbeats.BuildConfig +import com.darkxvenom.airbeats.checkForUpdates +import com.darkxvenom.airbeats.isNewerVersion +import androidx.compose.foundation.layout.height +import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.offset +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.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +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.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.unit.sp +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import coil.compose.AsyncImage +import coil.request.CachePolicy +import coil.request.ImageRequest +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.innertube.models.ArtistItem +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.innertube.models.YTItem +import com.darkxvenom.airbeats.innertube.utils.parseCookieString +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AccountNameKey +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.constants.InnerTubeCookieKey +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.constants.ListThumbnailSize +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.db.entities.Artist +import com.darkxvenom.airbeats.db.entities.LocalItem +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.LocalAlbumRadio +import com.darkxvenom.airbeats.playback.queues.YouTubeAlbumRadio +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.AlbumGridItem +import com.darkxvenom.airbeats.ui.component.ArtistGridItem +import com.darkxvenom.airbeats.ui.component.ChipsRow +import com.darkxvenom.airbeats.ui.component.HideOnScrollFAB +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.NavigationTitle +import com.darkxvenom.airbeats.ui.component.SongGridItem +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.YouTubeGridItem +import com.darkxvenom.airbeats.ui.component.shimmer.GridItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.component.shimmer.TextPlaceholder +import com.darkxvenom.airbeats.ui.menu.AlbumMenu +import com.darkxvenom.airbeats.ui.menu.ArtistMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeAlbumMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeArtistMenu +import com.darkxvenom.airbeats.ui.component.CircleIconButton +import com.darkxvenom.airbeats.ui.menu.YouTubePlaylistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeSongMenu +import com.darkxvenom.airbeats.ui.utils.SnapLayoutInfoProvider +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.HomeViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.min +import kotlin.random.Random +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.material3.Text +import androidx.core.net.toUri +import com.darkxvenom.airbeats.ui.component.LocalUserName +import com.darkxvenom.airbeats.ui.component.AvatarPreferenceManager +import com.darkxvenom.airbeats.ui.component.AvatarSelection +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.layout.PaddingValues +import kotlin.math.abs + +@SuppressLint("UnusedBoxWithConstraintsScope") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + navController: NavController, + viewModel: HomeViewModel = hiltViewModel(), +) +{ + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val playerConnection = LocalPlayerConnection.current ?: return + val haptic = LocalHapticFeedback.current + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val quickPicks by viewModel.quickPicks.collectAsState() + val forgottenFavorites by viewModel.forgottenFavorites.collectAsState() + val keepListening by viewModel.keepListening.collectAsState() + val similarRecommendations by viewModel.similarRecommendations.collectAsState() + val accountPlaylists by viewModel.accountPlaylists.collectAsState() + val homePage by viewModel.homePage.collectAsState() + val explorePage by viewModel.explorePage.collectAsState() + + val allLocalItems by viewModel.allLocalItems.collectAsState() + val allYtItems by viewModel.allYtItems.collectAsState() + + val isLoading: Boolean by viewModel.isLoading.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + val pullRefreshState = rememberPullToRefreshState() + + val quickPicksLazyGridState = rememberLazyGridState() + val forgottenFavoritesLazyGridState = rememberLazyGridState() + + val accountName by rememberPreference(AccountNameKey, "") + val accountImageUrl by viewModel.accountImageUrl.collectAsState() + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + val url = if (isLoggedIn) accountImageUrl else null + + val scope = rememberCoroutineScope() + val lazylistState = rememberLazyListState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = + backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + lazylistState.animateScrollToItem(0) + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + val localGridItem: @Composable (LocalItem) -> Unit = { + when (it) { + is Song -> SongGridItem( + song = it, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (it.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue.radio(it.toMediaMetadata()), + ) + } + }, + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + menuState.show { + SongMenu( + originalSong = it, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ), + isActive = it.id == mediaMetadata?.id, + isPlaying = isPlaying, + ) + + is Album -> AlbumGridItem( + album = it, + isActive = it.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + coroutineScope = scope, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${it.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = it, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + + is Artist -> ArtistGridItem( + artist = it, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("artist/${it.id}") + }, + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress, + ) + menuState.show { + ArtistMenu( + originalArtist = it, + coroutineScope = scope, + onDismiss = menuState::dismiss, + ) + } + }, + ), + ) + + is Playlist -> {} + } + } + + val ytGridItem: @Composable (YTItem) -> Unit = { item -> + YouTubeGridItem( + item = item, + isActive = item.id in listOf(mediaMetadata?.album?.id, mediaMetadata?.id), + isPlaying = isPlaying, + coroutineScope = scope, + thumbnailRatio = 1f, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue( + YouTubeQueue( + item.endpoint ?: WatchEndpoint( + videoId = item.id + ), item.toMediaMetadata() + ) + ) + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + playlist = item, + coroutineScope = scope, + onDismiss = menuState::dismiss + ) + } + } + } + ) + ) + } + + LaunchedEffect(quickPicks) { + quickPicksLazyGridState.scrollToItem(0) + } + + LaunchedEffect(forgottenFavorites) { + forgottenFavoritesLazyGridState.scrollToItem(0) + } + + // Main container Box that holds everything (same pattern as ExploreScreen) + Box(modifier = Modifier.fillMaxSize()) { + + // 🎵 BLUR BACKGROUND (exactly same as ExploreScreen) + val artworkUrl = mediaMetadata?.thumbnailUrl + + artworkUrl?.let { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + + val isDarkTheme = + MaterialTheme.colorScheme.background.luminance() < 0.5f + + val overlayBrush = if (isDarkTheme) { + Brush.verticalGradient( + listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.85f) + ) + ) + } else { + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.25f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + MaterialTheme.colorScheme.background.copy(alpha = 0.85f) + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(overlayBrush) + ) + } + + // 📜 CONTENT (LazyColumn with pull-to-refresh, exactly same as before but moved outside) + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .pullToRefresh( + state = pullRefreshState, + isRefreshing = isRefreshing, + onRefresh = viewModel::refresh + ), + contentAlignment = Alignment.TopStart + ) { + val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f + val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor + val quickPicksSnapLayoutInfoProvider = remember(quickPicksLazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = quickPicksLazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) + } + ) + } + val forgottenFavoritesSnapLayoutInfoProvider = remember(forgottenFavoritesLazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = forgottenFavoritesLazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) + } + ) + } + + // FIXED: Added proper windowInsetsPadding to move content down + LazyColumn( + state = lazylistState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier + .windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Top) + ) + ) + { + // ModernHomeTopBarInline is now inside the LazyColumn + item { + ModernHomeTopBarInline( + navController = navController + ) + } + + item { + Row( + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .fillMaxWidth() + .animateItem() + ) { + ChipsRow( + chips = listOfNotNull( + Pair("history", stringResource(R.string.history)), + Pair("stats", stringResource(R.string.stats)), + Pair("liked", stringResource(R.string.liked)), + Pair("downloads", stringResource(R.string.offline)), + if (isLoggedIn) Pair( + "account", + stringResource(R.string.account) + ) else null + ), + currentValue = "", + onValueUpdate = { value -> + when (value) { + "history" -> navController.navigate("history") + "stats" -> navController.navigate("stats") + "liked" -> navController.navigate("auto_playlist/liked") + "downloads" -> navController.navigate("auto_playlist/downloaded") + "account" -> if (isLoggedIn) navController.navigate("account") + } + }, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + } + } + + quickPicks?.takeIf { it.isNotEmpty() }?.let { picks -> + item { + val pagerState = rememberPagerState(pageCount = { picks.size }) + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 80.dp), + pageSpacing = 12.dp, + modifier = Modifier + .fillMaxWidth() + .height(280.dp) + .padding(vertical = 16.dp) + .animateItem() + ) { page -> + val originalSong = picks[page] + val song by database.song(originalSong.id).collectAsState(initial = originalSong) + + val pageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction + val scale = 1f - (abs(pageOffset) * 0.15f).coerceIn(0f, 0.15f) + + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(RoundedCornerShape(24.dp)) + .border(1.dp, Color.White.copy(alpha = 0.2f), RoundedCornerShape(24.dp)) + .combinedClickable( + onClick = { + if (song!!.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue.radio(song!!.toMediaMetadata())) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + SongMenu( + originalSong = song!!, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) { + AsyncImage( + model = song!!.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.8f) + ), + startY = 200f + ) + ) + ) + + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp) + ) { + Text( + text = song!!.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = song!!.artists.joinToString { it.name }, + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + + quickPicks?.takeIf { it.isNotEmpty() }?.let { quickPicks -> + item { + NavigationTitle( + title = stringResource(R.string.quick_picks), + modifier = Modifier.animateItem() + ) + } + + item { + LazyHorizontalGrid( + state = quickPicksLazyGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(quickPicksSnapLayoutInfoProvider), + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight * 4) + .animateItem() + ) { + items( + items = quickPicks, + key = { it.id } + ) { originalSong -> + // fetch song from database to keep updated + val song by database.song(originalSong.id) + .collectAsState(initial = originalSong) + + SongListItem( + song = song!!, + showInLibraryIcon = true, + isActive = song!!.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song!!, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .width(horizontalLazyGridItemWidth) + .combinedClickable( + onClick = { + if (song!!.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue.radio(song!!.toMediaMetadata())) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + SongMenu( + originalSong = song!!, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + } + } + } + + keepListening?.takeIf { it.isNotEmpty() }?.let { keepListening -> + item { + NavigationTitle( + title = stringResource(R.string.keep_listening), + modifier = Modifier.animateItem() + ) + } + + item { + val rows = if (keepListening.size > 6) 2 else 1 + LazyHorizontalGrid( + state = rememberLazyGridState(), + rows = GridCells.Fixed(rows), + modifier = Modifier + .fillMaxWidth() + .height((GridThumbnailHeight + with(LocalDensity.current) { + MaterialTheme.typography.bodyLarge.lineHeight.toDp() * 2 + + MaterialTheme.typography.bodyMedium.lineHeight.toDp() * 2 + }) * rows) + .animateItem() + ) { + items(keepListening) { + localGridItem(it) + } + } + } + } + + accountPlaylists?.takeIf { it.isNotEmpty() }?.let { accountPlaylists -> + item { + NavigationTitle( + label = stringResource(R.string.your_ytb_playlists), + title = accountName, + thumbnail = { + if (url != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(url) + .diskCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(url) + .crossfade(true) + .build(), + placeholder = painterResource(id = R.drawable.person), + error = painterResource(id = R.drawable.person), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(ListThumbnailSize) + .clip(CircleShape) + ) + } else { + Icon( + painter = painterResource(id = R.drawable.person), + contentDescription = null, + modifier = Modifier.size(ListThumbnailSize) + ) + } + }, + onClick = { + navController.navigate("account") + }, + modifier = Modifier.animateItem() + ) + } + + + item { + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier.animateItem() + ) { + items( + items = accountPlaylists, + key = { it.id }, + ) { item -> + ytGridItem(item) + } + } + } + } + + similarRecommendations?.forEach { + item { + NavigationTitle( + label = stringResource(R.string.similar_to), + title = it.title.title, + thumbnail = it.title.thumbnailUrl?.let { thumbnailUrl -> + { + val shape = + if (it.title is Artist) CircleShape else RoundedCornerShape( + ThumbnailCornerRadius + ) + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(shape) + ) + } + }, + onClick = { + when (it.title) { + is Song -> navController.navigate("album/${it.title.album!!.id}") + is Album -> navController.navigate("album/${it.title.id}") + is Artist -> navController.navigate("artist/${it.title.id}") + is Playlist -> {} + } + }, + modifier = Modifier.animateItem() + ) + } + + item { + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier.animateItem() + ) { + items(it.items) { item -> + ytGridItem(item) + } + } + } + } + + homePage?.sections?.forEach { + item { + NavigationTitle( + title = it.title, + label = it.label, + thumbnail = it.thumbnail?.let { thumbnailUrl -> + { + val shape = + if (it.endpoint?.isArtistEndpoint == true) CircleShape else RoundedCornerShape( + ThumbnailCornerRadius + ) + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(ListThumbnailSize) + .clip(shape) + ) + } + }, + modifier = Modifier.animateItem() + ) + } + + item { + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier.animateItem() + ) { + items(it.items) { item -> + ytGridItem(item) + } + } + } + } + + explorePage?.newReleaseAlbums?.let { newReleaseAlbums -> + item { + NavigationTitle( + title = stringResource(R.string.new_release_albums), + onClick = { + navController.navigate("new_release") + }, + modifier = Modifier.animateItem() + ) + } + + item { + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier.animateItem() + ) { + items( + items = newReleaseAlbums, + key = { it.id } + ) { album -> + YouTubeGridItem( + item = album, + isActive = mediaMetadata?.album?.id == album.id, + isPlaying = isPlaying, + coroutineScope = scope, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeAlbumMenu( + albumItem = album, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItem() + ) + } + } + } + } + + if (isLoading) { + item { + ShimmerHost( + modifier = Modifier.animateItem() + ) { + TextPlaceholder( + height = 36.dp, + modifier = Modifier + .padding(12.dp) + .width(250.dp), + ) + LazyRow { + items(4) { + GridItemPlaceHolder() + } + } + } + } + } + + forgottenFavorites?.takeIf { it.isNotEmpty() }?.let { forgottenFavorites -> + item { + NavigationTitle( + title = stringResource(R.string.forgotten_favorites), + modifier = Modifier.animateItem() + ) + } + + item { + // take min in case list size is less than 4 + val rows = min(4, forgottenFavorites.size) + LazyHorizontalGrid( + state = forgottenFavoritesLazyGridState, + rows = GridCells.Fixed(rows), + flingBehavior = rememberSnapFlingBehavior( + forgottenFavoritesSnapLayoutInfoProvider + ), + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight * rows) + .animateItem() + ) { + items( + items = forgottenFavorites, + key = { it.id } + ) { originalSong -> + val song by database.song(originalSong.id) + .collectAsState(initial = originalSong) + + SongListItem( + song = song!!, + showInLibraryIcon = true, + isActive = song!!.id == mediaMetadata?.id, + isPlaying = isPlaying, + modifier = Modifier + .width(horizontalLazyGridItemWidth) + .combinedClickable( + onClick = { + if (song!!.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue(YouTubeQueue.radio(song!!.toMediaMetadata())) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + SongMenu( + originalSong = song!!, + navController = navController, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + } + } + } + } + + HideOnScrollFAB( + visible = allLocalItems.isNotEmpty() || allYtItems.isNotEmpty(), + lazyListState = lazylistState, + icon = R.drawable.shuffle, + onClick = { + val local = when { + allLocalItems.isNotEmpty() && allYtItems.isNotEmpty() -> Random.nextFloat() < 0.5 + allLocalItems.isNotEmpty() -> true + else -> false + } + scope.launch(Dispatchers.Main) { + if (local) { + when (val luckyItem = allLocalItems.random()) { + is Song -> playerConnection.playQueue(YouTubeQueue.radio(luckyItem.toMediaMetadata())) + is Album -> { + val albumWithSongs = withContext(Dispatchers.IO) { + database.albumWithSongs(luckyItem.id).first() + } + albumWithSongs?.let { + playerConnection.playQueue(LocalAlbumRadio(it)) + } + } + + is Artist -> {} + is Playlist -> {} + } + } else { + when (val luckyItem = allYtItems.random()) { + is SongItem -> playerConnection.playQueue(YouTubeQueue.radio(luckyItem.toMediaMetadata())) + is AlbumItem -> playerConnection.playQueue(YouTubeAlbumRadio(luckyItem.playlistId)) + is ArtistItem -> luckyItem.radioEndpoint?.let { + playerConnection.playQueue(YouTubeQueue(it)) + } + + is PlaylistItem -> luckyItem.playEndpoint?.let { + playerConnection.playQueue(YouTubeQueue(it)) + } + } + } + } + } + ) + + Indicator( + isRefreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()), + ) + } + } +} +@Composable +fun ModernHomeTopBarInline( + navController: NavController +) { + val context = LocalContext.current + val avatarManager = remember { AvatarPreferenceManager(context) } + val currentSelection by avatarManager + .getAvatarSelection + .collectAsState(initial = AvatarSelection.Default) + + val playerConnection = LocalPlayerConnection.current + val isPlaying by playerConnection?.isPlaying?.collectAsState() ?: remember { mutableStateOf(false) } + + val userName = LocalUserName.current + val displayName = if (userName.isNotEmpty()) userName else "Friend" + + val currentVersion = BuildConfig.VERSION_NAME + var showUpdateIcon by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + try { + val latestVersion = withContext(Dispatchers.IO) { checkForUpdates() } + showUpdateIcon = + latestVersion?.let { isNewerVersion(it, currentVersion) } ?: false + } catch (_: Exception) { + showUpdateIcon = false + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Top) + ) + .padding(horizontal = 20.dp) + .padding(top = 24.dp, bottom = 12.dp) + ) { + + // 🔹 TOP ROW + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + + // 🔥 Avatar + Animated Ring + Box( + modifier = Modifier.size(72.dp), + contentAlignment = Alignment.Center + ) { + + val playerConnection = LocalPlayerConnection.current + val isPlaying by playerConnection?.isPlaying?.collectAsState() ?: remember { mutableStateOf(false) } + +// Simple colorful gradient (you can later extract from artwork) + val songColors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.tertiary + ) + + AnimatedBeatsRing( + isPlaying = isPlaying, + songColors = songColors, + modifier = Modifier.matchParentSize() + ) + + Box( + modifier = Modifier + .size(60.dp) + .clip(CircleShape) + .combinedClickable { + navController.navigate("settings/account") + }, + contentAlignment = Alignment.Center + ) { + when (currentSelection) { + + is AvatarSelection.Custom -> { + AsyncImage( + model = (currentSelection as AvatarSelection.Custom).uri.toUri(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + is AvatarSelection.DiceBear -> { + AsyncImage( + model = (currentSelection as AvatarSelection.DiceBear).url, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + else -> { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf( + Color(0xFF8E2DE2), + Color(0xFF4A00E0) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.person), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + } + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + CircleIconButton( + icon = R.drawable.notification_on, + onClick = { navController.navigate("new_release") } + ) + + CircleIconButton( + icon = R.drawable.search, + onClick = { navController.navigate("search/") } + ) + + CircleIconButton( + icon = if (showUpdateIcon) + R.drawable.update + else + R.drawable.settings, + onClick = { navController.navigate("settings") } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 🔹 GREETING + val greatVibesFontFamily = androidx.compose.ui.text.font.FontFamily(androidx.compose.ui.text.font.Font(com.darkxvenom.airbeats.R.font.great_vibes)) + Row( + modifier = Modifier + .padding(start = 8.dp) + .offset(y = (-10).dp) + .rotate(-8f) + ) { + + Text( + text = "Hi, ", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + fontFamily = greatVibesFontFamily, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = displayName, + fontFamily = greatVibesFontFamily, + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.ExtraBold, + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.tertiary + ) + ) + ) + ) + } + } +} + +@Composable +fun AnimatedBeatsRing( + isPlaying: Boolean, + songColors: List, + modifier: Modifier = Modifier +) { + if (!isPlaying) return + + val infiniteTransition = rememberInfiniteTransition(label = "beats_anim") + + val rotation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(6000), + repeatMode = RepeatMode.Restart + ), + label = "rotation" + ) + + val barAnim by infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(500), + repeatMode = RepeatMode.Reverse + ), + label = "bar_height" + ) + + Canvas( + modifier = modifier + .size(72.dp) + .graphicsLayer { rotationZ = rotation } + ) { + val radius = size.minDimension / 2 + val barCount = 40 + val angleStep = 360f / barCount + + for (i in 0 until barCount) { + + val angle = Math.toRadians((i * angleStep).toDouble()) + val dynamicHeight = radius * barAnim * (0.5f + (i % 5) * 0.1f) + + val startX = center.x + (radius - 12f) * cos(angle).toFloat() + val startY = center.y + (radius - 12f) * sin(angle).toFloat() + + val endX = center.x + (radius - 12f + dynamicHeight * 0.25f) * cos(angle).toFloat() + val endY = center.y + (radius - 12f + dynamicHeight * 0.25f) * sin(angle).toFloat() + + drawLine( + brush = Brush.linearGradient(songColors), + start = Offset(startX, startY), + end = Offset(endX, endY), + strokeWidth = 6f, + cap = StrokeCap.Round + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/InsightScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/InsightScreen.kt new file mode 100644 index 00000000..9c1e570c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/InsightScreen.kt @@ -0,0 +1,79 @@ +package com.darkxvenom.airbeats.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.darkxvenom.airbeats.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InsightScreen(navController: NavController) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF191414)) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TopAppBar( + title = { Text("AirBeats Insight", color = Color.White) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + painter = painterResource(id = R.drawable.arrow_back), + contentDescription = "Back", + tint = Color.White + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) + ) + + Spacer(modifier = Modifier.height(100.dp)) + + Icon( + painter = painterResource(id = R.drawable.auto_awesome), + contentDescription = null, + tint = Color(0xFF1DB954), + modifier = Modifier.padding(16.dp).height(64.dp) + ) + + Text( + text = "Coming Soon", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "The full AirBeats Insight experience is currently under development.", + color = Color.Gray, + modifier = Modifier.padding(horizontal = 32.dp) + ) + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/LoginScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/LoginScreen.kt new file mode 100644 index 00000000..7550d404 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/LoginScreen.kt @@ -0,0 +1,228 @@ +package com.darkxvenom.airbeats.ui.screens + +import android.annotation.SuppressLint +import android.webkit.CookieManager +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavController +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.utils.parseCookieString +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AccountChannelHandleKey +import com.darkxvenom.airbeats.constants.AccountEmailKey +import com.darkxvenom.airbeats.constants.AccountNameKey +import com.darkxvenom.airbeats.constants.InnerTubeCookieKey +import com.darkxvenom.airbeats.constants.VisitorDataKey +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.utils.reportException +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber + +private const val YOUTUBE_MUSIC_URL = "https://music.youtube.com" +private const val MAX_RETRY_ATTEMPTS = 3 +private const val RETRY_DELAY_MS = 1000L + +@SuppressLint("SetJavaScriptEnabled") +@OptIn(ExperimentalMaterial3Api::class, DelicateCoroutinesApi::class) +@Composable +fun LoginScreen(navController: NavController) { + var visitorData by rememberPreference(VisitorDataKey, "") + var innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + var accountName by rememberPreference(AccountNameKey, "") + var accountEmail by rememberPreference(AccountEmailKey, "") + var accountChannelHandle by rememberPreference(AccountChannelHandleKey, "") + + var webView: WebView? = null + var isLoadingAccountInfo by remember { mutableStateOf(false) } + + suspend fun fetchAccountInfoWithRetry(retryCount: Int = 0) { + try { + YouTube.accountInfo().onSuccess { accountInfo -> + // Verificar que la información no esté vacía + val name = accountInfo.name.takeIf { it.isNotBlank() } ?: "" + val email = accountInfo.email?.takeIf { it.isNotBlank() } ?: "" + val handle = accountInfo.channelHandle?.takeIf { it.isNotBlank() } ?: "" + + // Solo actualizar si tenemos al menos el nombre + if (name.isNotEmpty()) { + accountName = name + accountEmail = email + accountChannelHandle = handle + + Timber.tag("WebView") + .d("Account info retrieved successfully: $name, $email, $handle") + isLoadingAccountInfo = false + } else { + // Si el nombre está vacío, reintentar + if (retryCount < MAX_RETRY_ATTEMPTS) { + Timber.tag("WebView") + .w("Account name is empty, retrying... Attempt ${retryCount + 1}") + delay(RETRY_DELAY_MS) + fetchAccountInfoWithRetry(retryCount + 1) + } else { + Timber.tag("WebView") + .e("Failed to retrieve account name after $MAX_RETRY_ATTEMPTS attempts") + isLoadingAccountInfo = false + } + } + }.onFailure { exception -> + if (retryCount < MAX_RETRY_ATTEMPTS) { + Timber.tag("WebView") + .w("Failed to retrieve account info, retrying... Attempt ${retryCount + 1}: ${exception.message}") + delay(RETRY_DELAY_MS) + fetchAccountInfoWithRetry(retryCount + 1) + } else { + reportException(exception) + Timber.tag("WebView") + .e( + exception, + "Failed to retrieve account info after $MAX_RETRY_ATTEMPTS attempts" + ) + isLoadingAccountInfo = false + } + } + } catch (e: Exception) { + reportException(e) + Timber.tag("WebView").e(e, "Unexpected error while fetching account info") + isLoadingAccountInfo = false + } + } + + AndroidView( + modifier = + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .fillMaxSize(), + factory = { context -> + WebView(context).apply { + webViewClient = object : WebViewClient() { + override fun onPageStarted( + view: WebView, + url: String, + favicon: android.graphics.Bitmap? + ) { +// Timber.tag("WebView").d("Page started: $url") // Uncomment this line to debug WebView + super.onPageStarted(view, url, favicon) + } + + override fun onPageFinished(view: WebView, url: String?) { + Timber.tag("WebView").d("Page finished: $url") + if (url != null && url.startsWith(YOUTUBE_MUSIC_URL)) { + val youTubeCookieString = CookieManager.getInstance().getCookie(url) + val parsedCookies = parseCookieString(youTubeCookieString) + + if ("SAPISID" in parsedCookies) { + innerTubeCookie = youTubeCookieString + isLoadingAccountInfo = true + + GlobalScope.launch { + // Pequeña espera para asegurar que las cookies se establezcan correctamente + delay(500) + fetchAccountInfoWithRetry() + } + + // Obtener visitor data + loadUrl("javascript:Android.onRetrieveVisitorData(window.yt.config_.VISITOR_DATA)") + } else { + innerTubeCookie = "" + Timber.tag("WebView").e("SAPISID not found in cookies") + } + } + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + val url = request.url.toString() + Timber.tag("WebView").d("Loading URL: $url") + return super.shouldOverrideUrlLoading(view, request) + } + } + settings.apply { + javaScriptEnabled = true + setSupportZoom(true) + builtInZoomControls = true + } + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + addJavascriptInterface( + object { + @JavascriptInterface + fun onRetrieveVisitorData(newVisitorData: String?) { + if (innerTubeCookie.isEmpty()) { + visitorData = "" + Timber.tag("WebView") + .e("InnerTube cookie is empty, cannot retrieve visitor data") + return + } + + if (!newVisitorData.isNullOrBlank()) { + visitorData = newVisitorData + Timber.tag("WebView").d("Visitor data retrieved: $visitorData") + } else { + Timber.tag("WebView").w("Visitor data is null or blank") + } + } + }, + "Android", + ) + webView = this + loadUrl( + "https://accounts.google.com/ServiceLogin?ltmpl=music&service=youtube&passive=true&continue=$YOUTUBE_MUSIC_URL", + ) + } + }, + ) + + TopAppBar( + title = { + Text( + if (isLoadingAccountInfo) { + stringResource(R.string.login) + " - Loading..." + } else { + stringResource(R.string.login) + } + ) + }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + ) + + BackHandler(enabled = webView?.canGoBack() == true) { + webView?.goBack() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NavigationBuilder.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NavigationBuilder.kt new file mode 100644 index 00000000..2b1adb64 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NavigationBuilder.kt @@ -0,0 +1,289 @@ +package com.darkxvenom.airbeats.ui.screens + +import android.annotation.SuppressLint +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.darkxvenom.airbeats.BuildConfig +import com.darkxvenom.airbeats.ui.screens.artist.ArtistItemsScreen +import com.darkxvenom.airbeats.ui.screens.artist.ArtistScreen +import com.darkxvenom.airbeats.ui.screens.artist.ArtistSongsScreen +import com.darkxvenom.airbeats.ui.screens.library.CachePlaylistScreen +import com.darkxvenom.airbeats.ui.screens.library.LibraryScreen +import com.darkxvenom.airbeats.ui.screens.playlist.AutoPlaylistScreen +import com.darkxvenom.airbeats.ui.screens.playlist.LocalPlaylistScreen +import com.darkxvenom.airbeats.ui.screens.playlist.OnlinePlaylistScreen +import com.darkxvenom.airbeats.ui.screens.playlist.TopPlaylistScreen +import com.darkxvenom.airbeats.ui.screens.search.OnlineSearchResult +import com.darkxvenom.airbeats.ui.screens.settings.AboutScreen +import com.darkxvenom.airbeats.ui.screens.settings.AccountSettings +import com.darkxvenom.airbeats.ui.screens.settings.AppearanceSettings +import com.darkxvenom.airbeats.ui.screens.settings.BackupAndRestore +import com.darkxvenom.airbeats.ui.screens.settings.ContentSettings +import com.darkxvenom.airbeats.ui.screens.settings.DiscordLoginScreen +import com.darkxvenom.airbeats.ui.screens.settings.DiscordSettings +import com.darkxvenom.airbeats.ui.screens.settings.PlayerSettings +import com.darkxvenom.airbeats.ui.screens.settings.PrivacySettings +import com.darkxvenom.airbeats.ui.screens.settings.SettingsScreen +import com.darkxvenom.airbeats.ui.screens.settings.StorageSettings + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@SuppressLint("UnrememberedMutableState") +@OptIn(ExperimentalMaterial3Api::class) +fun NavGraphBuilder.navigationBuilder( + navController: NavHostController, + scrollBehavior: TopAppBarScrollBehavior, + latestVersionName: String, +) { + composable(Screens.Home.route) { + HomeScreen( + navController = navController + ) + + } + + composable( + Screens.Library.route, + ) { + LibraryScreen(navController) + } + composable(Screens.Explore.route) { + ExploreScreen(navController,scrollBehavior) + } + composable("history") { + HistoryScreen(navController) + } + composable("stats") { + StatsScreen(navController) + } + composable("account") { + AccountScreen(navController, scrollBehavior) + } + composable("new_release") { + NewReleaseScreen(navController, scrollBehavior) + } + composable("insight") { + InsightScreen(navController) + } + + + + + + + composable( + route = "search/{query}", + arguments = + listOf( + navArgument("query") { + type = NavType.StringType + }, + ), + enterTransition = { + fadeIn(tween(250)) + }, + exitTransition = { + if (targetState.destination.route?.startsWith("search/") == true) { + fadeOut(tween(200)) + } else { + fadeOut(tween(200)) + slideOutHorizontally { -it / 2 } + } + }, + popEnterTransition = { + if (initialState.destination.route?.startsWith("search/") == true) { + fadeIn(tween(250)) + } else { + fadeIn(tween(250)) + slideInHorizontally { -it / 2 } + } + }, + popExitTransition = { + fadeOut(tween(200)) + }, + ) { + OnlineSearchResult(navController) + } + composable( + route = "album/{albumId}", + arguments = + listOf( + navArgument("albumId") { + type = NavType.StringType + }, + ), + ) { + AlbumScreen(navController, scrollBehavior) + } + composable( + route = "artist/{artistId}", + arguments = + listOf( + navArgument("artistId") { + type = NavType.StringType + }, + ), + ) { backStackEntry -> + val artistId = backStackEntry.arguments?.getString("artistId")!! + if (artistId.startsWith("LA")) { + ArtistSongsScreen(navController, scrollBehavior) + } else { + ArtistScreen(navController, scrollBehavior) + } + } + composable( + route = "artist/{artistId}/songs", + arguments = + listOf( + navArgument("artistId") { + type = NavType.StringType + }, + ), + ) { + ArtistSongsScreen(navController, scrollBehavior) + } + composable( + route = "artist/{artistId}/items?browseId={browseId}?params={params}", + arguments = + listOf( + navArgument("artistId") { + type = NavType.StringType + }, + navArgument("browseId") { + type = NavType.StringType + nullable = true + }, + navArgument("params") { + type = NavType.StringType + nullable = true + }, + ), + ) { + ArtistItemsScreen(navController, scrollBehavior) + } + composable( + route = "online_playlist/{playlistId}", + arguments = + listOf( + navArgument("playlistId") { + type = NavType.StringType + }, + ), + ) { + OnlinePlaylistScreen(navController, scrollBehavior) + } + composable( + route = "local_playlist/{playlistId}", + arguments = + listOf( + navArgument("playlistId") { + type = NavType.StringType + }, + ), + ) { + LocalPlaylistScreen(navController, scrollBehavior) + } + composable( + route = "auto_playlist/{playlist}", + arguments = + listOf( + navArgument("playlist") { + type = NavType.StringType + }, + ), + ) { + AutoPlaylistScreen(navController, scrollBehavior) + } + composable( + route = "cache_playlist/{playlist}", + arguments = + listOf( + navArgument("playlist") { + type = NavType.StringType + }, + ), + ) { + CachePlaylistScreen(navController, scrollBehavior) + } + + + + composable( + route = "top_playlist/{top}", + arguments = + listOf( + navArgument("top") { + type = NavType.StringType + }, + ), + ) { + TopPlaylistScreen(navController, scrollBehavior) + } + composable( + route = "youtube_browse/{browseId}?params={params}", + arguments = + listOf( + navArgument("browseId") { + type = NavType.StringType + nullable = true + }, + navArgument("params") { + type = NavType.StringType + nullable = true + }, + ), + ) { + YouTubeBrowseScreen(navController) + } + + + composable("settings") { + val latestVersion by mutableLongStateOf(BuildConfig.VERSION_CODE.toLong()) + SettingsScreen(latestVersion, navController, scrollBehavior) + } + composable("settings/appearance") { + AppearanceSettings(navController, scrollBehavior) + } + composable("settings/account") { + AccountSettings(navController, scrollBehavior) + } + composable("settings/content") { + ContentSettings(navController, scrollBehavior) + } + composable("settings/player") { + PlayerSettings(navController, scrollBehavior) + } + composable("settings/storage") { + StorageSettings(navController, scrollBehavior) + } + composable("settings/privacy") { + PrivacySettings(navController, scrollBehavior) + } + composable("settings/backup_restore") { + BackupAndRestore(navController, scrollBehavior) + } + composable("settings/discord") { + DiscordSettings(navController, scrollBehavior) + } + composable("settings/discord/login") { + DiscordLoginScreen(navController) + } + composable("settings/about") { + AboutScreen(navController, scrollBehavior) + } + composable("login") { + LoginScreen(navController) + } +} + diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NewReleaseScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NewReleaseScreen.kt new file mode 100644 index 00000000..817a59de --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/NewReleaseScreen.kt @@ -0,0 +1,158 @@ +package com.darkxvenom.airbeats.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.YouTubeGridItem +import com.darkxvenom.airbeats.ui.component.shimmer.GridItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.menu.YouTubeAlbumMenu +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.viewmodels.NewReleaseViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun NewReleaseScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: NewReleaseViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() + val coroutineScope = rememberCoroutineScope() + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.new_release_albums), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold + ) + }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface + ) + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f) + ) + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { paddingValues -> + if (newReleaseAlbums.isNotEmpty()) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = paddingValues.calculateTopPadding() + 8.dp, + bottom = LocalPlayerAwareWindowInsets.current.asPaddingValues() + .calculateBottomPadding() + 16.dp + ), + modifier = Modifier.fillMaxSize() + ) { + items( + items = newReleaseAlbums, + key = { it.id }, + ) { album -> + YouTubeGridItem( + item = album, + isActive = mediaMetadata?.album?.id == album.id, + isPlaying = isPlaying, + fillMaxWidth = true, + coroutineScope = coroutineScope, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeAlbumMenu( + albumItem = album, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } + } else { + // Estado de carga + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = paddingValues.calculateTopPadding() + 8.dp, + bottom = LocalPlayerAwareWindowInsets.current.asPaddingValues() + .calculateBottomPadding() + 16.dp + ), + modifier = Modifier.fillMaxSize() + ) { + items(8) { + ShimmerHost { + GridItemPlaceHolder(fillMaxWidth = true) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/Screens.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/Screens.kt new file mode 100644 index 00000000..a49a18ff --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/Screens.kt @@ -0,0 +1,39 @@ +package com.darkxvenom.airbeats.ui.screens + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import com.darkxvenom.airbeats.R + +@Immutable +sealed class Screens( + @StringRes val titleId: Int, + @DrawableRes val iconIdInactive: Int, + @DrawableRes val iconIdActive: Int, + val route: String, +) { + data object Home : Screens( + titleId = R.string.home, + iconIdInactive = R.drawable.home_outlined, + iconIdActive = R.drawable.home_filled, + route = "home" + ) + + data object Explore : Screens( + titleId = R.string.explore, + iconIdInactive = R.drawable.explore_outlined, + iconIdActive = R.drawable.explore_filled, + route = "explore" + ) + + data object Library : Screens( + titleId = R.string.filter_library, + iconIdInactive = R.drawable.library_music_outlined, + iconIdActive = R.drawable.library_music_filled, + route = "library" + ) + + companion object { + val MainScreens = listOf(Home, Explore, Library) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/StatsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/StatsScreen.kt new file mode 100644 index 00000000..3e63e225 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/StatsScreen.kt @@ -0,0 +1,754 @@ +package com.darkxvenom.airbeats.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +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.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.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.text.style.TextOverflow +import coil.compose.AsyncImage +import androidx.compose.ui.draw.blur +import androidx.compose.ui.layout.ContentScale +import androidx.compose.material3.TopAppBarDefaults +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.StatPeriod +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.ChoiceChipsRow +import com.darkxvenom.airbeats.ui.component.HideOnScrollFAB +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalAlbumsGrid +import com.darkxvenom.airbeats.ui.component.LocalArtistsGrid +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.LocalSongsGrid +import com.darkxvenom.airbeats.ui.component.NavigationTitle +import com.darkxvenom.airbeats.ui.menu.AlbumMenu +import com.darkxvenom.airbeats.ui.menu.ArtistMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.joinByBullet +import com.darkxvenom.airbeats.utils.makeTimeString +import com.darkxvenom.airbeats.viewmodels.StatsViewModel +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun StatsScreen( + navController: NavController, + viewModel: StatsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val context = LocalContext.current + + val indexChips by viewModel.indexChips.collectAsState() + val mostPlayedSongs by viewModel.mostPlayedSongs.collectAsState() + val mostPlayedSongsStats by viewModel.mostPlayedSongsStats.collectAsState() + val mostPlayedArtists by viewModel.mostPlayedArtists.collectAsState() + val mostPlayedAlbums by viewModel.mostPlayedAlbums.collectAsState() + val firstEvent by viewModel.firstEvent.collectAsState() + val currentDate = LocalDateTime.now() + + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + val selectedOption by viewModel.selectedOption.collectAsState() + + // BottomSheet para Insight + var showInsightBottomSheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val weeklyDates = + if (currentDate != null && firstEvent != null) { + generateSequence(currentDate) { it.minusWeeks(1) } + .takeWhile { it.isAfter(firstEvent?.event?.timestamp?.minusWeeks(1)) } + .mapIndexed { index, date -> + val endDate = date.plusWeeks(1).minusDays(1).coerceAtMost(currentDate) + val formatter = DateTimeFormatter.ofPattern("dd MMM") + + val startDateFormatted = formatter.format(date) + val endDateFormatted = formatter.format(endDate) + + val startMonth = date.month + val endMonth = endDate.month + val startYear = date.year + val endYear = endDate.year + + val text = + when { + startYear != currentDate.year -> "$startDateFormatted, $startYear - $endDateFormatted, $endYear" + startMonth != endMonth -> "$startDateFormatted - $endDateFormatted" + else -> "${date.dayOfMonth} - $endDateFormatted" + } + Pair(index, text) + }.toList() + } else { + emptyList() + } + + val monthlyDates = + if (currentDate != null && firstEvent != null) { + generateSequence( + currentDate.plusMonths(1).withDayOfMonth(1).minusDays(1) + ) { it.minusMonths(1) } + .takeWhile { + it.isAfter( + firstEvent + ?.event + ?.timestamp + ?.withDayOfMonth(1), + ) + }.mapIndexed { index, date -> + val formatter = DateTimeFormatter.ofPattern("MMM") + val formattedDate = formatter.format(date) + val text = + if (date.year != currentDate.year) { + "$formattedDate ${date.year}" + } else { + formattedDate + } + Pair(index, text) + }.toList() + } else { + emptyList() + } + + val yearlyDates = + if (currentDate != null && firstEvent != null) { + generateSequence( + currentDate + .plusYears(1) + .withDayOfYear(1) + .minusDays(1), + ) { it.minusYears(1) } + .takeWhile { + it.isAfter( + firstEvent + ?.event + ?.timestamp, + ) + }.mapIndexed { index, date -> + Pair(index, "${date.year}") + }.toList() + } else { + emptyList() + } + + Box(modifier = Modifier.fillMaxSize()) { + val artworkUrl = mediaMetadata?.thumbnailUrl + artworkUrl?.let { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + val isDarkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f + val overlayBrush = if (isDarkTheme) { + Brush.verticalGradient( + listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.85f) + ) + ) + } else { + Brush.verticalGradient( + listOf( + Color.White.copy(alpha = 0.2f), + Color.White.copy(alpha = 0.5f), + Color.White.copy(alpha = 0.85f) + ) + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .background(overlayBrush) + ) + } + + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + .asPaddingValues(), + modifier = Modifier.windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top) + ) + ) { + item { + ChoiceChipsRow( + chips = + when (selectedOption) { + OptionStats.WEEKS -> weeklyDates + OptionStats.MONTHS -> monthlyDates + OptionStats.YEARS -> yearlyDates + OptionStats.CONTINUOUS -> { + listOf( + StatPeriod.WEEK_1.ordinal to pluralStringResource( + R.plurals.n_week, + 1, + 1 + ), + StatPeriod.MONTH_1.ordinal to pluralStringResource( + R.plurals.n_month, + 1, + 1 + ), + StatPeriod.MONTH_3.ordinal to pluralStringResource( + R.plurals.n_month, + 3, + 3 + ), + StatPeriod.MONTH_6.ordinal to pluralStringResource( + R.plurals.n_month, + 6, + 6 + ), + StatPeriod.YEAR_1.ordinal to pluralStringResource( + R.plurals.n_year, + 1, + 1 + ), + StatPeriod.ALL.ordinal to stringResource(R.string.filter_all), + ) + } + }, + options = + listOf( + OptionStats.CONTINUOUS to stringResource(id = R.string.continuous), + OptionStats.WEEKS to stringResource(R.string.weeks), + OptionStats.MONTHS to stringResource(R.string.months), + OptionStats.YEARS to stringResource(R.string.years), + ), + selectedOption = selectedOption, + onSelectionChange = { + viewModel.selectedOption.value = it + viewModel.indexChips.value = 0 + }, + currentValue = indexChips, + onValueUpdate = { viewModel.indexChips.value = it }, + ) + } + + item { + val totalTime = mostPlayedSongsStats.sumOf { it.timeListened?.toLong() ?: 0L } + val totalSongs = mostPlayedSongsStats.size + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "Total Time", style = MaterialTheme.typography.labelMedium) + Text(text = makeTimeString(totalTime), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = "Unique Songs", style = MaterialTheme.typography.labelMedium) + Text(text = "$totalSongs", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + } + } + } + + item { + if (mostPlayedSongsStats.isNotEmpty()) { + val maxPlayCount = mostPlayedSongsStats.maxOf { it.songCountListened } + val top5 = mostPlayedSongsStats.take(5) + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Top 5 Songs", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(12.dp)) + top5.forEachIndexed { index, song -> + val fraction = if (maxPlayCount > 0) song.songCountListened.toFloat() / maxPlayCount else 0f + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text("${index + 1}. ${song.title}", modifier = Modifier.weight(0.4f), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall) + Box(modifier = Modifier.weight(0.6f).height(12.dp).background(Color.Gray.copy(alpha = 0.2f), RoundedCornerShape(6.dp))) { + Box(modifier = Modifier.fillMaxWidth(fraction).height(12.dp).background(Color(0xFF1DB954), RoundedCornerShape(6.dp))) + } + Spacer(modifier = Modifier.width(8.dp)) + Text("${song.songCountListened}", style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + } + + item(key = "mostPlayedSongs") { + NavigationTitle( + title = "${mostPlayedSongsStats.size} ${stringResource(id = R.string.songs)}", + modifier = Modifier.animateItem(), + ) + + LazyRow( + modifier = Modifier.animateItem(), + ) { + itemsIndexed( + items = mostPlayedSongsStats, + key = { _, song -> song.id }, + ) { index, song -> + LocalSongsGrid( + title = "${index + 1}. ${song.title}", + subtitle = + joinByBullet( + pluralStringResource( + R.plurals.n_time, + song.songCountListened, + song.songCountListened, + ), + makeTimeString(song.timeListened), + ), + thumbnailUrl = song.thumbnailUrl, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + endpoint = WatchEndpoint(song.id), + preloadItem = mostPlayedSongs[index].toMediaMetadata(), + ), + ) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + SongMenu( + originalSong = mostPlayedSongs[index], + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } + } + + item(key = "mostPlayedArtists") { + NavigationTitle( + title = "${mostPlayedArtists.size} ${stringResource(id = R.string.artists)}", + modifier = Modifier.animateItem(), + ) + + LazyRow( + modifier = Modifier.animateItem(), + ) { + itemsIndexed( + items = mostPlayedArtists, + key = { _, artist -> artist.id }, + ) { index, artist -> + LocalArtistsGrid( + title = "${index + 1}. ${artist.artist.name}", + subtitle = + joinByBullet( + pluralStringResource( + R.plurals.n_time, + artist.songCount, + artist.songCount + ), + makeTimeString(artist.timeListened?.toLong()), + ), + thumbnailUrl = artist.artist.thumbnailUrl, + modifier = + Modifier + .combinedClickable( + onClick = { + navController.navigate("artist/${artist.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + ArtistMenu( + originalArtist = artist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } + } + + item(key = "mostPlayedAlbums") { + NavigationTitle( + title = "${mostPlayedAlbums.size} ${stringResource(id = R.string.albums)}", + modifier = Modifier.animateItem(), + ) + + if (mostPlayedAlbums.isNotEmpty()) { + LazyRow( + modifier = Modifier.animateItem(), + ) { + itemsIndexed( + items = mostPlayedAlbums, + key = { _, album -> album.id }, + ) { index, album -> + LocalAlbumsGrid( + title = "${index + 1}. ${album.album.title}", + subtitle = + joinByBullet( + pluralStringResource( + R.plurals.n_time, + album.songCountListened!!, + album.songCountListened + ), + makeTimeString(album.timeListened?.toLong()), + ), + thumbnailUrl = album.album.thumbnailUrl, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = album, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } + } + } + } + + // FAB to shuffle most played songs + if (mostPlayedSongs.isNotEmpty()) { + HideOnScrollFAB( + visible = true, + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.most_played_songs), + items = mostPlayedSongs.map { it.toMediaMetadata().toMediaItem() } + .shuffled() + ) + ) + } + ) + } + + TopAppBar( + title = { Text(stringResource(R.string.stats)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ), + actions = { + IconButton( + onClick = { showInsightBottomSheet = true }, + modifier = Modifier.size(48.dp), + enabled = true, + onLongClick = {} + ) { + Icon( + painter = painterResource(R.drawable.auto_awesome), + contentDescription = "AirBeats Insight", + tint = Color(0xFF1DB954) + ) + } + } + ) + } + + // BottomSheet de Insight + if (showInsightBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showInsightBottomSheet = false }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + InsightBottomSheetContent( + onNavigateToFullInsight = { + coroutineScope.launch { + sheetState.hide() + showInsightBottomSheet = false + } + navController.navigate("insight") + }, + onDismiss = { + coroutineScope.launch { + sheetState.hide() + showInsightBottomSheet = false + } + } + ) + } + } +} + +@Composable +fun InsightBottomSheetContent( + onNavigateToFullInsight: () -> Unit, + onDismiss: () -> Unit +) { + val currentYear = LocalDateTime.now().year + val gradientColors = listOf( + Color(0xFF1DB954), + Color(0xFF1ED760), + Color(0xFF191414) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp, start = 16.dp, end = 16.dp, top = 8.dp) + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.auto_awesome), // o bar_chart + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = Color(0xFF1DB954) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "AirBeats Insight", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "Discover your musical year", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + // Card principal + Card( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient(gradientColors) + ) + .padding(24.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Your Musical Year", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + + Column { + Text( + text = "$currentYear", + fontSize = 48.sp, + fontWeight = FontWeight.Black, + color = Color.White + ) + Text( + text = "Tap to view your full statistics", + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.9f) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Características + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + InsightFeatureItem( + icon = "🎵", + label = "Top\nSongs" + ) + InsightFeatureItem( + icon = "🎤", + label = "Top\nArtists" + ) + InsightFeatureItem( + icon = "📊", + label = "Full\nStatistics" + ) + InsightFeatureItem( + icon = "📥", + label = "Download\nReport" + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Botón para ver completo + Button( + onClick = onNavigateToFullInsight, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "View Full AirBeats Insight", + modifier = Modifier.padding(8.dp), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +@Composable +fun InsightFeatureItem( + icon: String, + label: String +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.width(80.dp) + ) { + Text( + text = icon, + fontSize = 32.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + fontSize = 11.sp, + lineHeight = 14.sp + ) + } +} + +enum class OptionStats { WEEKS, MONTHS, YEARS, CONTINUOUS } \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/YouTubeBrowseScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/YouTubeBrowseScreen.kt new file mode 100644 index 00000000..8e6c068c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/YouTubeBrowseScreen.kt @@ -0,0 +1,282 @@ +package com.darkxvenom.airbeats.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +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.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.innertube.models.ArtistItem +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.NavigationTitle +import com.darkxvenom.airbeats.ui.component.YouTubeGridItem +import com.darkxvenom.airbeats.ui.component.YouTubeListItem +import com.darkxvenom.airbeats.ui.component.shimmer.ListItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.menu.YouTubeAlbumMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeArtistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubePlaylistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeSongMenu +import com.darkxvenom.airbeats.ui.utils.SnapLayoutInfoProvider +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.viewmodels.YouTubeBrowseViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun YouTubeBrowseScreen( + navController: NavController, + viewModel: YouTubeBrowseViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val browseResult by viewModel.result.collectAsState() + + val coroutineScope = rememberCoroutineScope() + + val songsLazyGridState = rememberLazyGridState() + + Box( + modifier = Modifier.fillMaxSize(), + ) { + val snapLayoutInfoProviderSongs = + remember(songsLazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = songsLazyGridState, + ) + } + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + if (browseResult == null) { + item { + ShimmerHost { + repeat(8) { + ListItemPlaceHolder() + } + } + } + } + + browseResult?.items?.fastForEach { + if (it.items.isNotEmpty()) { + it.title?.let { title -> + item { + NavigationTitle(title) + } + } + + if ((it.items.firstOrNull() as? SongItem)?.album != null) { + item { + LazyHorizontalGrid( + state = songsLazyGridState, + rows = GridCells.Fixed(5), + flingBehavior = + rememberSnapFlingBehavior( + snapLayoutInfoProviderSongs, + ), + contentPadding = + WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = + Modifier + .fillMaxWidth() + .height(ListItemHeight * 5), + ) { + items( + items = it.items, + ) { song -> + Box(Modifier.width(350.dp)) { + YouTubeListItem( + item = song as SongItem, + isActive = mediaMetadata?.id == song.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .clickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint( + videoId = song.id, + ), + song.toMediaMetadata(), + ), + ) + } + } + .animateItem(), + ) + } + } + } + } + } else { + item { + LazyRow { + items( + items = it.items, + ) { item -> + YouTubeGridItem( + item = item, + isActive = + when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + modifier = + Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = item.id), + item.toMediaMetadata(), + ), + ) + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate( + "artist/${item.id}" + ) + + is PlaylistItem -> navController.navigate( + "online_playlist/${item.id}" + ) + } + }, + onLongClick = { + haptic.performHapticFeedback( + HapticFeedbackType.LongPress + ) + menuState.show { + when (item) { + is SongItem -> + YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is AlbumItem -> + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is ArtistItem -> + YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss, + ) + + is PlaylistItem -> + YouTubePlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + } + }, + ) + .animateItem(), + ) + } + } + } + } + } + } + } + } + TopAppBar( + title = { Text(browseResult?.title.orEmpty()) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistItemsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistItemsScreen.kt new file mode 100644 index 00000000..3f90cded --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistItemsScreen.kt @@ -0,0 +1,297 @@ +package com.darkxvenom.airbeats.ui.screens.artist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.innertube.models.ArtistItem +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.YouTubeGridItem +import com.darkxvenom.airbeats.ui.component.YouTubeListItem +import com.darkxvenom.airbeats.ui.component.shimmer.GridItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ListItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.menu.YouTubeAlbumMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeArtistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubePlaylistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeSongMenu +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.viewmodels.ArtistItemsViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ArtistItemsScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: ArtistItemsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + val coroutineScope = rememberCoroutineScope() + + val title by viewModel.title.collectAsState() + val itemsPage by viewModel.itemsPage.collectAsState() + + LaunchedEffect(lazyListState) { + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + LaunchedEffect(lazyGridState) { + snapshotFlow { + lazyGridState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + if (itemsPage == null) { + ShimmerHost( + modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current), + ) { + repeat(8) { + ListItemPlaceHolder() + } + } + } + + if (itemsPage?.items?.firstOrNull() is SongItem) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id }, + ) { item -> + YouTubeListItem( + item = item, + isActive = + when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + when (item) { + is SongItem -> + YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is AlbumItem -> + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is ArtistItem -> + YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss, + ) + + is PlaylistItem -> + YouTubePlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .clickable { + when (item) { + is SongItem -> { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + item.endpoint + ?: WatchEndpoint(videoId = item.id), + item.toMediaMetadata() + ), + ) + } + } + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + ) + } + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost { + repeat(3) { + ListItemPlaceHolder() + } + } + } + } + } + } else { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + fillMaxWidth = true, + coroutineScope = coroutineScope, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue( + YouTubeQueue( + item.endpoint ?: WatchEndpoint(videoId = item.id), + item.toMediaMetadata() + ) + ) + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + ) + .animateItem() + ) + } + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost(Modifier.animateItem()) { + GridItemPlaceHolder(fillMaxWidth = true) + } + } + } + } + } + + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + ) +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistScreen.kt new file mode 100644 index 00000000..8c34ff46 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistScreen.kt @@ -0,0 +1,680 @@ +package com.darkxvenom.airbeats.ui.screens.artist + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.util.Log +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +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.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.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.innertube.models.ArtistItem +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AppBarHeight +import com.darkxvenom.airbeats.db.entities.ArtistEntity +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.NavigationTitle +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.YouTubeGridItem +import com.darkxvenom.airbeats.ui.component.YouTubeListItem +import com.darkxvenom.airbeats.ui.component.shimmer.ButtonPlaceholder +import com.darkxvenom.airbeats.ui.component.shimmer.ListItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.component.shimmer.TextPlaceholder +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeAlbumMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeArtistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubePlaylistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeSongMenu +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.ui.utils.fadingEdge +import com.darkxvenom.airbeats.ui.utils.resize +import com.darkxvenom.airbeats.viewmodels.ArtistViewModel +import com.valentinilk.shimmer.shimmer + +@SuppressLint("ServiceCast") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ArtistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: ArtistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val artistPage = viewModel.artistPage + val libraryArtist by viewModel.libraryArtist.collectAsState() + val librarySongs by viewModel.librarySongs.collectAsState() + + val lazyListState = rememberLazyListState() + + val transparentAppBar by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset < 100 + } + } + + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .add( + WindowInsets( + top = -WindowInsets.systemBars.asPaddingValues() + .calculateTopPadding() - AppBarHeight + ) + ) + .asPaddingValues(), + ) { + artistPage.let { + if (artistPage != null) { + item(key = "header") { + Column { + // Hero Header con imagen de fondo desvanecida + Box( + modifier = Modifier + .fillMaxWidth() + .height(380.dp) + ) { + // Imagen de fondo con desvanecimiento + Box( + modifier = Modifier.fillMaxSize() + ) { + AsyncImage( + model = artistPage.artist.thumbnail.resize(1200, 1000), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Transparent, + MaterialTheme.colorScheme.surface + ), + startY = 0f, + endY = 1100f + ) + ) + ) + } + + // Gradiente adicional para mejor legibilidad + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.4f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + MaterialTheme.colorScheme.surface + ), + startY = 0f, + endY = 1400f + ) + ) + ) + + // Contenido sobre la imagen + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(horizontal = 20.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Avatar circular del artista + Surface( + shape = CircleShape, + shadowElevation = 8.dp, + modifier = Modifier.size(110.dp) + ) { + AsyncImage( + model = artistPage.artist.thumbnail.resize(220, 220), + contentDescription = null, + contentScale = ContentScale.Crop + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Nombre del artista con sombra sutil + Text( + text = artistPage.artist.title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Barra de acciones principales + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // Botón Suscribirse/Suscrito + Button( + onClick = { + database.transaction { + val artist = libraryArtist?.artist + if (artist != null) { + update(artist.toggleLike()) + } else { + artistPage.artist.let { + insert( + ArtistEntity( + id = it.id, + name = it.title, + channelId = it.channelId, + thumbnailUrl = it.thumbnail, + ).toggleLike() + ) + } + } + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (libraryArtist?.artist?.bookmarkedAt != null) + MaterialTheme.colorScheme.secondaryContainer + else + MaterialTheme.colorScheme.primary, + contentColor = if (libraryArtist?.artist?.bookmarkedAt != null) + MaterialTheme.colorScheme.onSecondaryContainer + else + MaterialTheme.colorScheme.onPrimary + ), + shape = RoundedCornerShape(24.dp), + modifier = Modifier.height(44.dp) + ) { + Icon( + painter = painterResource( + if (libraryArtist?.artist?.bookmarkedAt != null) + R.drawable.library_add_check + else + R.drawable.library_add + ), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (libraryArtist?.artist?.bookmarkedAt != null) + stringResource(R.string.subscribed) + else + stringResource(R.string.subscribe), + fontWeight = FontWeight.SemiBold + ) + } + + Spacer(Modifier.weight(1f)) + + // Botón Shuffle + artistPage.artist.shuffleEndpoint?.let { shuffleEndpoint -> + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(44.dp) + ) { + IconButton( + onClick = { + playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) + } + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = stringResource(R.string.shuffle), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp) + ) + } + } + } + + // Botón Compartir + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.size(44.dp) + ) { + IconButton( + onClick = { + artistPage.artist.shareLink?.let { link -> + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Artist Link", link) + clipboard.setPrimaryClip(clip) + Toast.makeText( + context, + R.string.link_copied, + Toast.LENGTH_SHORT + ).show() + } + } + ) { + Icon( + painter = painterResource(R.drawable.link), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + } + + // Canciones de la biblioteca + if (librarySongs.isNotEmpty()) { + item { + NavigationTitle( + title = stringResource(R.string.from_your_library), + onClick = { + navController.navigate("artist/${viewModel.artistId}/songs") + }, + ) + } + + items( + items = librarySongs, + key = { "local_${it.id}" }, + ) { song -> + SongListItem( + song = song, + showInLibraryIcon = true, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = song.id), + song.toMediaMetadata(), + ), + ) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } + + // Secciones del artista + artistPage.sections.fastForEach { section -> + if (section.items.isNotEmpty()) { + item { + NavigationTitle( + title = section.title, + onClick = section.moreEndpoint?.let { + { + navController.navigate( + "artist/${viewModel.artistId}/items?browseId=${it.browseId}?params=${it.params}", + ) + } + }, + ) + } + } + + if ((section.items.firstOrNull() as? SongItem)?.album != null) { + items( + items = section.items, + key = { it.id }, + ) { song -> + YouTubeListItem( + item = song as SongItem, + isActive = mediaMetadata?.id == song.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = Modifier + .combinedClickable( + onClick = { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = song.id), + song.toMediaMetadata() + ), + ) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + } else { + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = section.items, + key = { it.id }, + ) { item -> + YouTubeGridItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = item.id), + item.toMediaMetadata() + ), + ) + is AlbumItem -> { + try { + if (item.id.isNotEmpty()) { + navController.navigate("album/${item.id}") + } else { + Log.w("ArtistScreen", "Album ID is empty") + Toast.makeText( + context, + "Error: Invalid album ID", + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.e("ArtistScreen", "Navigation error to album: ${item.id}", e) + Toast.makeText( + context, + "Navigation error", + Toast.LENGTH_SHORT + ).show() + } + } + is ArtistItem -> { + try { + if (item.id.isNotEmpty()) { + navController.navigate("artist/${item.id}") + } else { + Log.w("ArtistScreen", "Artist ID is empty") + } + } catch (e: Exception) { + Log.e("ArtistScreen", "Navigation error to artist: ${item.id}", e) + } + } + is PlaylistItem -> { + try { + if (item.id.isNotEmpty()) { + navController.navigate("online_playlist/${item.id}") + } else { + Log.w("ArtistScreen", "Playlist ID is empty") + } + } catch (e: Exception) { + Log.e("ArtistScreen", "Navigation error to playlist: ${item.id}", e) + } + } + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + is AlbumItem -> YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + is ArtistItem -> YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss, + ) + is PlaylistItem -> YouTubePlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + } + }, + ) + .animateItem(), + ) + } + } + } + } + } + } else { + // Estado de carga + item(key = "shimmer") { + ShimmerHost { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3), + ) { + Spacer( + modifier = Modifier + .shimmer() + .background(MaterialTheme.colorScheme.onSurface) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 108.dp, + ), + ) + TextPlaceholder( + height = 56.dp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp), + ) + } + + Row( + modifier = Modifier.padding(12.dp), + ) { + ButtonPlaceholder(Modifier.weight(1f)) + Spacer(Modifier.width(12.dp)) + ButtonPlaceholder(Modifier.weight(1f)) + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + } + + // TopAppBar con transición suave + TopAppBar( + title = { + if (!transparentAppBar) { + Text( + text = artistPage?.artist?.title.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + colors = if (transparentAppBar) { + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ) + } else { + TopAppBarDefaults.topAppBarColors() + }, + scrollBehavior = scrollBehavior + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistSongsScreen.kt new file mode 100644 index 00000000..221e0f1e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/artist/ArtistSongsScreen.kt @@ -0,0 +1,403 @@ +package com.darkxvenom.airbeats.ui.screens.artist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ArtistSongSortDescendingKey +import com.darkxvenom.airbeats.constants.ArtistSongSortType +import com.darkxvenom.airbeats.constants.ArtistSongSortTypeKey +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_HEADER +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.ui.component.HideOnScrollFAB +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.SortHeader +import com.darkxvenom.airbeats.ui.component.VerticalFastScroller +import com.darkxvenom.airbeats.ui.menu.SelectionSongMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.utils.ItemWrapper +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.ArtistSongsViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ArtistSongsScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: ArtistSongsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val (sortType, onSortTypeChange) = rememberEnumPreference( + ArtistSongSortTypeKey, + ArtistSongSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference( + ArtistSongSortDescendingKey, + true + ) + val artist by viewModel.artist.collectAsState() + val songs by viewModel.songs.collectAsState() + val lazyListState = rememberLazyListState() + + // Estados para búsqueda + var searchQuery by remember { mutableStateOf(TextFieldValue("")) } + var isSearching by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + + // Estado para selección múltiple + var selection by remember { mutableStateOf(false) } + + // Envolver canciones para selección + val wrappedSongs = remember(songs) { + songs.map { song -> ItemWrapper(song) } + } + + // Filtrar canciones por búsqueda + val searchQueryStr = searchQuery.text.trim() + val filteredSongs = if (searchQueryStr.isEmpty()) { + wrappedSongs + } else { + wrappedSongs.filter { wrapper -> + wrapper.item.song.title.contains(searchQueryStr, ignoreCase = true) || + wrapper.item.artists.joinToString("") + .contains(searchQueryStr, ignoreCase = true) + } + } + + // Auto-focus cuando se activa búsqueda + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } else { + focusManager.clearFocus() + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + VerticalFastScroller( + listState = lazyListState, + thumbColor = MaterialTheme.colorScheme.primary, + topContentPadding = 0.dp, + endContentPadding = 8.dp + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + if (!isSearching) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSongSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSongSortType.NAME -> R.string.sort_by_name + ArtistSongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + }, + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource( + R.plurals.n_song, + filteredSongs.size, + filteredSongs.size + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + ) + } + } + } + + itemsIndexed( + items = filteredSongs, + key = { _, item -> item.item.id }, + ) { index, songWrapper -> + SongListItem( + song = songWrapper.item, + showInLibraryIcon = true, + isActive = songWrapper.item.id == mediaMetadata?.id, + isPlaying = isPlaying, + isSelected = songWrapper.isSelected && selection, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (songWrapper.item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = filteredSongs.map { it.item.toMediaItem() }, + startIndex = index, + ), + ) + } + } else { + songWrapper.isSelected = !songWrapper.isSelected + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + } + wrappedSongs.forEach { it.isSelected = false } + songWrapper.isSelected = true + }, + ) + .animateItem(), + ) + } + } + } + + TopAppBar( + title = { + when { + selection -> { + val count = wrappedSongs.count { it.isSelected } + Text( + text = pluralStringResource(R.plurals.n_song, count, count), + style = MaterialTheme.typography.titleLarge + ) + } + + isSearching -> { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + } + + else -> { + Text(artist?.artist?.name.orEmpty()) + } + } + }, + navigationIcon = { + IconButton( + onClick = { + when { + isSearching -> { + isSearching = false + searchQuery = TextFieldValue() + } + + selection -> { + selection = false + wrappedSongs.forEach { it.isSelected = false } + } + + else -> navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + }, + ) { + Icon( + painterResource( + when { + isSearching -> R.drawable.close + selection -> R.drawable.close + else -> R.drawable.arrow_back + } + ), + contentDescription = null, + ) + } + }, + actions = { + when { + selection -> { + val count = wrappedSongs.count { it.isSelected } + + // Botón seleccionar/deseleccionar todo + IconButton( + onClick = { + if (count == wrappedSongs.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs.forEach { it.isSelected = true } + } + }, + ) { + Icon( + painter = painterResource( + if (count == wrappedSongs.size) R.drawable.deselect else R.drawable.select_all + ), + contentDescription = null + ) + } + + // Menú de opciones para selección + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs.filter { it.isSelected } + .map { it.item }, + onDismiss = menuState::dismiss, + clearAction = { + selection = false + wrappedSongs.forEach { it.isSelected = false } + }, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } + + !isSearching -> { + // Botón de búsqueda + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + } + }, + ) + + // FAB para shuffle (solo visible cuando no hay búsqueda ni selección) + if (!isSearching && !selection && filteredSongs.isNotEmpty()) { + HideOnScrollFAB( + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + playerConnection.playQueue( + ListQueue( + title = artist?.artist?.name, + items = filteredSongs.shuffled().map { it.item.toMediaItem() }, + ), + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/CachePlaylistScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/CachePlaylistScreen.kt new file mode 100644 index 00000000..c7760d51 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/CachePlaylistScreen.kt @@ -0,0 +1,424 @@ +package com.darkxvenom.airbeats.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +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.Spacer +import androidx.compose.foundation.layout.asPaddingValues +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AlbumThumbnailSize +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.ui.component.EmptyPlaceholder +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.menu.SelectionSongMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.utils.ItemWrapper +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.viewmodels.HistoryViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun CachePlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: HistoryViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val haptic = LocalHapticFeedback.current + val focusManager = LocalFocusManager.current + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val events by viewModel.events.collectAsState() + + val playerCache = LocalPlayerConnection.current?.service?.playerCache + + val cachedSongIds = remember(playerCache) { + playerCache?.keys?.mapNotNull { it.toString() }?.toSet() ?: emptySet() + } + + val allSongs = remember(events, cachedSongIds) { + events.values.flatten() + .map { it.song } + .distinctBy { it.id } + .filter { it.id in cachedSongIds } + } + + val wrappedSongs = remember(allSongs) { + mutableStateListOf>().apply { + addAll(allSongs.map { ItemWrapper(it) }) + } + } + + var selection by remember { mutableStateOf(false) } + var isSearching by remember { mutableStateOf(false) } + var query by remember { mutableStateOf(TextFieldValue()) } + val focusRequester = remember { FocusRequester() } + val lazyListState = rememberLazyListState() + + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + + val filteredSongs = remember(wrappedSongs, query) { + if (query.text.isEmpty()) wrappedSongs + else wrappedSongs.filter { wrapper -> + val song = wrapper.item + song.title.contains(query.text, true) || + song.artists.any { it.name.contains(query.text, true) } + } + } + + val state = rememberLazyListState() + + Box( + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + state = state, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + if (filteredSongs.isEmpty()) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty) + ) + } + } else { + if (!isSearching) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) { + AsyncImage( + model = filteredSongs.first().item.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + } + Column( + verticalArrangement = Arrangement.Center, + ) { + Text( + stringResource(R.string.cached_playlist), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Text( + text = pluralStringResource( + id = R.plurals.n_song, + count = filteredSongs.size, + filteredSongs.size + ), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = "Cache Songs", + items = filteredSongs.map { it.item.toMediaItem() }, + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue( + ListQueue( + title = "Cache Songs", + items = filteredSongs.shuffled() + .map { it.item.toMediaItem() }, + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + } + + itemsIndexed( + filteredSongs, + key = { _, song -> song.item.id }) { index, songWrapper -> + SongListItem( + song = songWrapper.item, + isActive = songWrapper.item.id == mediaMetadata?.id, + isPlaying = isPlaying, + isSelected = songWrapper.isSelected && selection, + showInLibraryIcon = true, + trailingContent = { + IconButton(onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (songWrapper.item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = "Cache Songs", + items = filteredSongs.map { it.item.toMediaItem() }, + startIndex = index + ) + ) + } + } else { + songWrapper.isSelected = !songWrapper.isSelected + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + wrappedSongs.forEach { it.isSelected = false } + songWrapper.isSelected = true + } + } + ) + .animateItem() + ) + } + } + } + + TopAppBar( + title = { + when { + selection -> { + val count = wrappedSongs.count { it.isSelected } + Text( + text = pluralStringResource(R.plurals.n_song, count, count), + style = MaterialTheme.typography.titleLarge + ) + } + + isSearching -> { + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + } + + else -> { + Text( + stringResource(R.string.cached_playlist), + style = MaterialTheme.typography.titleLarge + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { + when { + isSearching -> { + isSearching = false + query = TextFieldValue() + focusManager.clearFocus() + } + + selection -> { + selection = false + } + + else -> { + navController.navigateUp() + } + } + }, onLongClick = { + if (!isSearching && !selection) { + navController.backToMain() + } + }) { + Icon( + painter = painterResource( + if (selection) R.drawable.close else R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (selection) { + val count = wrappedSongs.count { it.isSelected } + IconButton(onClick = { + if (count == wrappedSongs.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs.forEach { it.isSelected = true } + } + }) { + Icon( + painter = painterResource( + if (count == wrappedSongs.size) R.drawable.deselect else R.drawable.select_all + ), + contentDescription = null + ) + } + + IconButton(onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs.filter { it.isSelected } + .map { it.item }, + onDismiss = menuState::dismiss, + clearAction = { selection = false } + ) + } + }) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else if (!isSearching) { + IconButton(onClick = { isSearching = true }) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryAlbumsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryAlbumsScreen.kt new file mode 100644 index 00000000..7c4a5bfd --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryAlbumsScreen.kt @@ -0,0 +1,307 @@ +package com.darkxvenom.airbeats.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +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.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AlbumFilter +import com.darkxvenom.airbeats.constants.AlbumFilterKey +import com.darkxvenom.airbeats.constants.AlbumSortDescendingKey +import com.darkxvenom.airbeats.constants.AlbumSortType +import com.darkxvenom.airbeats.constants.AlbumSortTypeKey +import com.darkxvenom.airbeats.constants.AlbumViewTypeKey +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_ALBUM +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_HEADER +import com.darkxvenom.airbeats.constants.GridItemSize +import com.darkxvenom.airbeats.constants.GridItemsSizeKey +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.constants.LibraryViewType +import com.darkxvenom.airbeats.constants.YtmSyncKey +import com.darkxvenom.airbeats.ui.component.ChipsRow +import com.darkxvenom.airbeats.ui.component.EmptyPlaceholder +import com.darkxvenom.airbeats.ui.component.LibraryAlbumGridItem +import com.darkxvenom.airbeats.ui.component.LibraryAlbumListItem +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.SortHeader +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.LibraryAlbumsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryAlbumsScreen( + navController: NavController, + onDeselect: () -> Unit, + viewModel: LibraryAlbumsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + var viewType by rememberEnumPreference(AlbumViewTypeKey, LibraryViewType.GRID) + var filter by rememberEnumPreference(AlbumFilterKey, AlbumFilter.LIKED) + val (sortType, onSortTypeChange) = rememberEnumPreference( + AlbumSortTypeKey, + AlbumSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(AlbumSortDescendingKey, true) + val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) + + val (ytmSync) = rememberPreference(YtmSyncKey, true) + + val filterContent = @Composable { + Row { + Spacer(Modifier.width(12.dp)) + FilterChip( + label = { Text(stringResource(R.string.albums)) }, + selected = true, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.surface), + onClick = onDeselect, + shape = RoundedCornerShape(16.dp), + leadingIcon = { + Icon(painter = painterResource(R.drawable.close), contentDescription = "") + }, + ) + ChipsRow( + chips = + listOf( + AlbumFilter.LIKED to stringResource(R.string.filter_liked), + AlbumFilter.LIBRARY to stringResource(R.string.filter_library) + ), + currentValue = filter, + onValueUpdate = { + filter = it + }, + modifier = Modifier.weight(1f), + ) + } + } + + LaunchedEffect(filter) { + if (ytmSync && filter == AlbumFilter.LIKED) { + withContext(Dispatchers.IO) { + viewModel.sync() + } + } + } + + val albums by viewModel.allAlbums.collectAsState() + + val coroutineScope = rememberCoroutineScope() + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = + backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + when (viewType) { + LibraryViewType.LIST -> lazyListState.animateScrollToItem(0) + LibraryViewType.GRID -> lazyGridState.animateScrollToItem(0) + } + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + AlbumSortType.CREATE_DATE -> R.string.sort_by_create_date + AlbumSortType.NAME -> R.string.sort_by_name + AlbumSortType.ARTIST -> R.string.sort_by_artist + AlbumSortType.YEAR -> R.string.sort_by_year + AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count + AlbumSortType.LENGTH -> R.string.sort_by_length + AlbumSortType.PLAY_TIME -> R.string.sort_by_play_time + } + }, + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource(R.plurals.n_album, albums.size, albums.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + ) + + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(start = 6.dp, end = 6.dp), + ) { + Icon( + painter = + painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view + }, + ), + contentDescription = null, + ) + } + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + when (viewType) { + LibraryViewType.LIST -> + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + + albums.let { albums -> + if (albums.isEmpty()) { + item { + EmptyPlaceholder( + icon = R.drawable.album, + text = stringResource(R.string.library_album_empty), + modifier = Modifier.animateItem() + ) + } + } + + items( + items = albums, + key = { it.id }, + contentType = { CONTENT_TYPE_ALBUM }, + ) { album -> + LibraryAlbumListItem( + navController = navController, + menuState = menuState, + album = album, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + modifier = Modifier.animateItem() + ) + } + } + } + + LibraryViewType.GRID -> + LazyVerticalGrid( + state = lazyGridState, + columns = + GridCells.Adaptive( + minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp, + ), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + + albums.let { albums -> + if (albums.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + EmptyPlaceholder( + icon = R.drawable.album, + text = stringResource(R.string.library_album_empty), + modifier = Modifier.animateItem() + ) + } + } + + items( + items = albums, + key = { it.id }, + contentType = { CONTENT_TYPE_ALBUM }, + ) { album -> + LibraryAlbumGridItem( + navController = navController, + menuState = menuState, + coroutineScope = coroutineScope, + album = album, + isActive = album.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + modifier = Modifier.animateItem() + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryArtistsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryArtistsScreen.kt new file mode 100644 index 00000000..abd8b463 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryArtistsScreen.kt @@ -0,0 +1,296 @@ +package com.darkxvenom.airbeats.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +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.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ArtistFilter +import com.darkxvenom.airbeats.constants.ArtistFilterKey +import com.darkxvenom.airbeats.constants.ArtistSortDescendingKey +import com.darkxvenom.airbeats.constants.ArtistSortType +import com.darkxvenom.airbeats.constants.ArtistSortTypeKey +import com.darkxvenom.airbeats.constants.ArtistViewTypeKey +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_ARTIST +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_HEADER +import com.darkxvenom.airbeats.constants.GridItemSize +import com.darkxvenom.airbeats.constants.GridItemsSizeKey +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.constants.LibraryViewType +import com.darkxvenom.airbeats.constants.YtmSyncKey +import com.darkxvenom.airbeats.ui.component.ChipsRow +import com.darkxvenom.airbeats.ui.component.EmptyPlaceholder +import com.darkxvenom.airbeats.ui.component.LibraryArtistGridItem +import com.darkxvenom.airbeats.ui.component.LibraryArtistListItem +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.SortHeader +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.LibraryArtistsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryArtistsScreen( + navController: NavController, + onDeselect: () -> Unit, + viewModel: LibraryArtistsViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + var viewType by rememberEnumPreference(ArtistViewTypeKey, LibraryViewType.GRID) + + var filter by rememberEnumPreference(ArtistFilterKey, ArtistFilter.LIKED) + val (sortType, onSortTypeChange) = rememberEnumPreference( + ArtistSortTypeKey, + ArtistSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(ArtistSortDescendingKey, true) + val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) + + val (ytmSync) = rememberPreference(YtmSyncKey, true) + + val filterContent = @Composable { + Row { + Spacer(Modifier.width(12.dp)) + FilterChip( + label = { Text(stringResource(R.string.artists)) }, + selected = true, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.surface), + onClick = onDeselect, + shape = RoundedCornerShape(16.dp), + leadingIcon = { + Icon(painter = painterResource(R.drawable.close), contentDescription = "") + }, + ) + ChipsRow( + chips = + listOf( + ArtistFilter.LIKED to stringResource(R.string.filter_liked), + ArtistFilter.LIBRARY to stringResource(R.string.filter_library) + ), + currentValue = filter, + onValueUpdate = { + filter = it + }, + modifier = Modifier.weight(1f), + ) + } + } + + LaunchedEffect(filter) { + if (ytmSync && filter == ArtistFilter.LIKED) { + withContext(Dispatchers.IO) { + viewModel.sync() + } + } + } + + val artists by viewModel.allArtists.collectAsState() + val coroutineScope = rememberCoroutineScope() + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = + backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + when (viewType) { + LibraryViewType.LIST -> lazyListState.animateScrollToItem(0) + LibraryViewType.GRID -> lazyGridState.animateScrollToItem(0) + } + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSortType.NAME -> R.string.sort_by_name + ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count + ArtistSortType.PLAY_TIME -> R.string.sort_by_play_time + } + }, + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource(R.plurals.n_artist, artists.size, artists.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + ) + + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(start = 6.dp, end = 6.dp), + ) { + Icon( + painter = + painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view + }, + ), + contentDescription = null, + ) + } + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + when (viewType) { + LibraryViewType.LIST -> + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + + artists.let { artists -> + if (artists.isEmpty()) { + item { + EmptyPlaceholder( + icon = R.drawable.artist, + text = stringResource(R.string.library_artist_empty), + modifier = Modifier.animateItem() + ) + } + } + + items( + items = artists, + key = { it.id }, + contentType = { CONTENT_TYPE_ARTIST }, + ) { artist -> + LibraryArtistListItem( + navController = navController, + menuState = menuState, + coroutineScope = coroutineScope, + modifier = Modifier.animateItem(), + artist = artist + ) + } + } + } + + LibraryViewType.GRID -> + LazyVerticalGrid( + state = lazyGridState, + columns = + GridCells.Adaptive( + minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp, + ), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + + artists.let { artists -> + if (artists.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + EmptyPlaceholder( + icon = R.drawable.artist, + text = stringResource(R.string.library_artist_empty), + modifier = Modifier.animateItem() + ) + } + } + + items( + items = artists, + key = { it.id }, + contentType = { CONTENT_TYPE_ARTIST }, + ) { artist -> + LibraryArtistGridItem( + navController = navController, + menuState = menuState, + coroutineScope = coroutineScope, + modifier = Modifier.animateItem(), + artist = artist + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryMixScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryMixScreen.kt new file mode 100644 index 00000000..fc7e8cb9 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryMixScreen.kt @@ -0,0 +1,722 @@ +package com.darkxvenom.airbeats.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +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.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AlbumViewTypeKey +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_HEADER +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_PLAYLIST +import com.darkxvenom.airbeats.constants.GridItemSize +import com.darkxvenom.airbeats.constants.GridItemsSizeKey +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.constants.LibraryViewType +import com.darkxvenom.airbeats.constants.MixSortDescendingKey +import com.darkxvenom.airbeats.constants.MixSortType +import com.darkxvenom.airbeats.constants.MixSortTypeKey +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.db.entities.Artist +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.extensions.reversed +import com.darkxvenom.airbeats.ui.component.AlbumGridItem +import com.darkxvenom.airbeats.ui.component.AlbumListItem +import com.darkxvenom.airbeats.ui.component.ArtistGridItem +import com.darkxvenom.airbeats.ui.component.ArtistListItem +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.PlaylistGridItem +import com.darkxvenom.airbeats.ui.component.PlaylistListItem +import com.darkxvenom.airbeats.ui.component.SortHeader +import com.darkxvenom.airbeats.ui.menu.AlbumMenu +import com.darkxvenom.airbeats.ui.menu.ArtistMenu +import com.darkxvenom.airbeats.ui.menu.PlaylistMenu +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.LibraryMixViewModel +import java.text.Collator +import java.time.LocalDateTime +import java.util.Locale +import java.util.UUID + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryMixScreen( + navController: NavController, + filterContent: @Composable () -> Unit, + onLocalClick: () -> Unit = {}, + viewModel: LibraryMixViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + var viewType by rememberEnumPreference(AlbumViewTypeKey, LibraryViewType.GRID) + val (sortType, onSortTypeChange) = rememberEnumPreference( + MixSortTypeKey, + MixSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(MixSortDescendingKey, true) + val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) + + val topSize by viewModel.topValue.collectAsState(initial = 50) + val likedPlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.liked) + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val downloadPlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.offline) + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val topPlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.my_top) + " $topSize" + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val cachePlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.cached_playlist) + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val localPlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.filter_local) + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val albums = viewModel.albums.collectAsState() + val artist = viewModel.artists.collectAsState() + val playlist = viewModel.playlists.collectAsState() + + var allItems = albums.value + artist.value + playlist.value + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + allItems = + when (sortType) { + MixSortType.CREATE_DATE -> + allItems.sortedBy { item -> + when (item) { + is Album -> item.album.bookmarkedAt + is Artist -> item.artist.bookmarkedAt + is Playlist -> item.playlist.createdAt + else -> LocalDateTime.now() + } + } + + MixSortType.NAME -> + allItems.sortedWith( + compareBy(collator) { item -> + when (item) { + is Album -> item.album.title + is Artist -> item.artist.name + is Playlist -> item.playlist.name + else -> "" + } + }, + ) + + MixSortType.LAST_UPDATED -> + allItems.sortedBy { item -> + when (item) { + is Album -> item.album.lastUpdateTime + is Artist -> item.artist.lastUpdateTime + is Playlist -> item.playlist.lastUpdateTime + else -> LocalDateTime.now() + } + } + }.reversed(sortDescending) + + val coroutineScope = rememberCoroutineScope() + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = + backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + when (viewType) { + LibraryViewType.LIST -> lazyListState.animateScrollToItem(0) + LibraryViewType.GRID -> lazyGridState.animateScrollToItem(0) + } + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + MixSortType.CREATE_DATE -> R.string.sort_by_create_date + MixSortType.LAST_UPDATED -> R.string.sort_by_last_updated + MixSortType.NAME -> R.string.sort_by_name + } + }, + ) + + Spacer(Modifier.weight(1f)) + + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(start = 6.dp, end = 6.dp), + ) { + Icon( + painter = + painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view + }, + ), + contentDescription = null, + ) + } + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + when (viewType) { + LibraryViewType.LIST -> + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + + item( + key = "likedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = likedPlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + navController.navigate("auto_playlist/liked") + } + .animateItem(), + ) + } + + item( + key = "downloadedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = downloadPlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + navController.navigate("auto_playlist/downloaded") + } + .animateItem(), + ) + } + + item( + key = "TopPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = topPlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + navController.navigate("top_playlist/$topSize") + } + .animateItem(), + ) + } + item( + key = "cachePlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = cachePlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + navController.navigate("cache_playlist/cached") + } + .animateItem(), + ) + } + + item( + key = "localPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = localPlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + onLocalClick() + } + .animateItem(), + ) + } + + items( + items = allItems, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { item -> + when (item) { + is Playlist -> { + PlaylistListItem( + playlist = item, + trailingContent = { + IconButton( + onClick = { + menuState.show { + PlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("local_playlist/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + PlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + + is Artist -> { + ArtistListItem( + artist = item, + trailingContent = { + IconButton( + onClick = { + menuState.show { + ArtistMenu( + originalArtist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("artist/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + ArtistMenu( + originalArtist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + + is Album -> { + AlbumListItem( + album = item, + isActive = item.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + AlbumMenu( + originalAlbum = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + + else -> {} + } + } + } + + LibraryViewType.GRID -> + LazyVerticalGrid( + state = lazyGridState, + columns = + GridCells.Adaptive( + minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp, + ), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + + item( + key = "likedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = likedPlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/liked") + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + item( + key = "downloadedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = downloadPlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/downloaded") + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + item( + key = "TopPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = topPlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("top_playlist/$topSize") + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + item( + key = "cachePlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = cachePlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("cache_playlist/cached") + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + item( + key = "localPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = localPlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + onLocalClick() + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + items( + items = allItems, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { item -> + when (item) { + is Playlist -> { + PlaylistGridItem( + playlist = item, + fillMaxWidth = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("local_playlist/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + PlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + is Artist -> { + ArtistGridItem( + artist = item, + fillMaxWidth = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("artist/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + ArtistMenu( + originalArtist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + + is Album -> { + AlbumGridItem( + album = item, + isActive = item.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + coroutineScope = coroutineScope, + fillMaxWidth = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("album/${item.id}") + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + AlbumMenu( + originalAlbum = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) + .animateItem(), + ) + } + + else -> {} + } + } + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryPlaylistsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryPlaylistsScreen.kt new file mode 100644 index 00000000..ceed5e45 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryPlaylistsScreen.kt @@ -0,0 +1,554 @@ +package com.darkxvenom.airbeats.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +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.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.darkxvenom.airbeats.innertube.utils.parseCookieString +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_HEADER +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_PLAYLIST +import com.darkxvenom.airbeats.constants.GridItemSize +import com.darkxvenom.airbeats.constants.GridItemsSizeKey +import com.darkxvenom.airbeats.constants.GridThumbnailHeight +import com.darkxvenom.airbeats.constants.InnerTubeCookieKey +import com.darkxvenom.airbeats.constants.LibraryViewType +import com.darkxvenom.airbeats.constants.PlaylistSortDescendingKey +import com.darkxvenom.airbeats.constants.PlaylistSortType +import com.darkxvenom.airbeats.constants.PlaylistSortTypeKey +import com.darkxvenom.airbeats.constants.PlaylistViewTypeKey +import com.darkxvenom.airbeats.constants.YtmSyncKey +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.ui.component.CreatePlaylistDialog +import com.darkxvenom.airbeats.ui.component.HideOnScrollFAB +import com.darkxvenom.airbeats.ui.component.LibraryPlaylistGridItem +import com.darkxvenom.airbeats.ui.component.LibraryPlaylistListItem +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.PlaylistGridItem +import com.darkxvenom.airbeats.ui.component.PlaylistListItem +import com.darkxvenom.airbeats.ui.component.SortHeader +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.LibraryPlaylistsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.UUID + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryPlaylistsScreen( + navController: NavController, + filterContent: @Composable () -> Unit, + onLocalClick: () -> Unit = {}, + viewModel: LibraryPlaylistsViewModel = hiltViewModel(), + initialTextFieldValue: String? = null, + allowSyncing: Boolean = true, + + ) { + val menuState = LocalMenuState.current + LocalHapticFeedback.current + + val coroutineScope = rememberCoroutineScope() + + var viewType by rememberEnumPreference(PlaylistViewTypeKey, LibraryViewType.GRID) + val (sortType, onSortTypeChange) = rememberEnumPreference( + PlaylistSortTypeKey, + PlaylistSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference( + PlaylistSortDescendingKey, + true + ) + val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) + + val playlists by viewModel.allPlaylists.collectAsState() + + val topSize by viewModel.topValue.collectAsState(initial = 50) + + val likedPlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.liked) + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val downloadPlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.offline) + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val topPlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.my_top) + " $topSize" + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val cachePlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.cached_playlist) + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val localPlaylist = + Playlist( + playlist = PlaylistEntity( + id = UUID.randomUUID().toString(), + name = stringResource(R.string.filter_local) + ), + songCount = 0, + thumbnails = emptyList(), + ) + + val lazyListState = rememberLazyListState() + val lazyGridState = rememberLazyGridState() + + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = + backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + val (innerTubeCookie) = rememberPreference(InnerTubeCookieKey, "") + remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + val (ytmSync) = rememberPreference(YtmSyncKey, true) + + LaunchedEffect(Unit) { + if (ytmSync) { + withContext(Dispatchers.IO) { + viewModel.sync() + } + } + } + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + when (viewType) { + LibraryViewType.LIST -> lazyListState.animateScrollToItem(0) + LibraryViewType.GRID -> lazyGridState.animateScrollToItem(0) + } + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + var showCreatePlaylistDialog by rememberSaveable { mutableStateOf(false) } + + if (showCreatePlaylistDialog) { + CreatePlaylistDialog( + onDismiss = { showCreatePlaylistDialog = false }, + initialTextFieldValue = initialTextFieldValue, + allowSyncing = allowSyncing + ) + } + + val headerContent = @Composable { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + PlaylistSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSortType.NAME -> R.string.sort_by_name + PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count + PlaylistSortType.LAST_UPDATED -> R.string.sort_by_last_updated + } + }, + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource( + R.plurals.n_playlist, + playlists.size, + playlists.size + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + ) + + IconButton( + onClick = { + viewType = viewType.toggle() + }, + modifier = Modifier.padding(start = 6.dp, end = 6.dp), + ) { + Icon( + painter = + painterResource( + when (viewType) { + LibraryViewType.LIST -> R.drawable.list + LibraryViewType.GRID -> R.drawable.grid_view + }, + ), + contentDescription = null, + ) + } + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + when (viewType) { + LibraryViewType.LIST -> { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + + item( + key = "likedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = likedPlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + navController.navigate("auto_playlist/liked") + } + .animateItem(), + ) + } + + item( + key = "downloadedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = downloadPlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + navController.navigate("auto_playlist/downloaded") + } + .animateItem(), + ) + } + + item( + key = "TopPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = topPlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + navController.navigate("top_playlist/$topSize") + } + .animateItem(), + ) + } + + item( + key = "cachePlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = cachePlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + navController.navigate("cache_playlist/cached") + } + .animateItem(), + ) + } + + item( + key = "localPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistListItem( + playlist = localPlaylist, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { + onLocalClick() + } + .animateItem(), + ) + } + + playlists.let { playlists -> + if (playlists.isEmpty()) { + item { + } + } + + items( + items = playlists, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { playlist -> + LibraryPlaylistListItem( + navController = navController, + menuState = menuState, + coroutineScope = coroutineScope, + playlist = playlist, + modifier = Modifier.animateItem() + ) + } + } + } + + HideOnScrollFAB( + lazyListState = lazyListState, + icon = R.drawable.add, + onClick = { + showCreatePlaylistDialog = true + }, + ) + } + + LibraryViewType.GRID -> { + LazyVerticalGrid( + state = lazyGridState, + columns = + GridCells.Adaptive( + minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp, + ), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + filterContent() + } + + item( + key = "header", + span = { GridItemSpan(maxLineSpan) }, + contentType = CONTENT_TYPE_HEADER, + ) { + headerContent() + } + + item( + key = "likedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = likedPlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/liked") + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + item( + key = "downloadedPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = downloadPlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("auto_playlist/downloaded") + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + item( + key = "TopPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = topPlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("top_playlist/$topSize") + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + item( + key = "cachePlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = cachePlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + navController.navigate("cache_playlist/cached") + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + item( + key = "localPlaylist", + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { + PlaylistGridItem( + playlist = localPlaylist, + fillMaxWidth = true, + autoPlaylist = true, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + onLocalClick() + }, + ) + .animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + + playlists.let { playlists -> + if (playlists.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + } + } + + items( + items = playlists, + key = { it.id }, + contentType = { CONTENT_TYPE_PLAYLIST }, + ) { playlist -> + LibraryPlaylistGridItem( + navController = navController, + menuState = menuState, + coroutineScope = coroutineScope, + playlist = playlist, + modifier = Modifier.animateItem(), + context = LocalContext.current // Pasamos el contexto actual para obtener la URI de la miniatura + ) + } + } + } + + HideOnScrollFAB( + lazyListState = lazyGridState, + icon = R.drawable.add, + onClick = { + showCreatePlaylistDialog = true + }, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryScreen.kt new file mode 100644 index 00000000..d0f33d12 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibraryScreen.kt @@ -0,0 +1,164 @@ +package com.darkxvenom.airbeats.ui.screens.library + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ChipSortTypeKey +import com.darkxvenom.airbeats.constants.LibraryFilter +import com.darkxvenom.airbeats.ui.component.ChipsRow +import com.darkxvenom.airbeats.ui.component.VerticalFastScroller +import com.darkxvenom.airbeats.utils.rememberEnumPreference + +@Composable +fun LibraryScreen(navController: NavController) { + + val playerConnection = LocalPlayerConnection.current ?: return + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + var filterType by remember { mutableStateOf(LibraryFilter.LIBRARY) } + val lazyListState = rememberLazyListState() + + val filterContent = @Composable { + Row { + ChipsRow( + chips = listOf( + LibraryFilter.PLAYLISTS to stringResource(R.string.filter_playlists), + LibraryFilter.SONGS to stringResource(R.string.filter_songs), + LibraryFilter.ALBUMS to stringResource(R.string.filter_albums), + LibraryFilter.ARTISTS to stringResource(R.string.filter_artists), + LibraryFilter.LOCAL to stringResource(R.string.filter_local), + ), + currentValue = filterType, + onValueUpdate = { + filterType = + if (filterType == it) { + LibraryFilter.LIBRARY + } else { + it + } + }, + modifier = Modifier.weight(1f), + ) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + + // 🎵 Blur background + val artworkUrl = mediaMetadata?.thumbnailUrl + + artworkUrl?.let { imageUrl -> + + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + + val isDarkTheme = + MaterialTheme.colorScheme.background.luminance() < 0.5f + + val overlayBrush = if (isDarkTheme) { + Brush.verticalGradient( + listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.85f) + ) + ) + } else { + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.25f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + MaterialTheme.colorScheme.background.copy(alpha = 0.85f) + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(overlayBrush) + ) + } + + // 🔝 Top Header + Text( + text = "Library", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 16.dp) + .align(Alignment.TopStart) + ) + + // 📚 Content + VerticalFastScroller( + listState = lazyListState, + topContentPadding = 80.dp, + endContentPadding = 0.dp + ) { + when (filterType) { + LibraryFilter.LIBRARY -> + LibraryMixScreen( + navController = navController, + filterContent = filterContent, + onLocalClick = { filterType = LibraryFilter.LOCAL } + ) + + LibraryFilter.PLAYLISTS -> + LibraryPlaylistsScreen( + navController = navController, + filterContent = filterContent, + onLocalClick = { filterType = LibraryFilter.LOCAL } + ) + + LibraryFilter.SONGS -> + LibrarySongsScreen( + navController, + { filterType = LibraryFilter.LIBRARY } + ) + + LibraryFilter.ALBUMS -> + LibraryAlbumsScreen( + navController, + { filterType = LibraryFilter.LIBRARY } + ) + + LibraryFilter.ARTISTS -> + LibraryArtistsScreen( + navController, + { filterType = LibraryFilter.LIBRARY } + ) + + // ── NEW: Local songs filter ─────────────────────────────────── + LibraryFilter.LOCAL -> + LocalSongsScreen(navController = navController) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibrarySongsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibrarySongsScreen.kt new file mode 100644 index 00000000..078050a0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LibrarySongsScreen.kt @@ -0,0 +1,342 @@ +package com.darkxvenom.airbeats.ui.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_HEADER +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_SONG +import com.darkxvenom.airbeats.constants.SongFilter +import com.darkxvenom.airbeats.constants.SongFilterKey +import com.darkxvenom.airbeats.constants.SongSortDescendingKey +import com.darkxvenom.airbeats.constants.SongSortType +import com.darkxvenom.airbeats.constants.SongSortTypeKey +import com.darkxvenom.airbeats.constants.YtmSyncKey +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.ui.component.ChipsRow +import com.darkxvenom.airbeats.ui.component.HideOnScrollFAB +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.SortHeader +import com.darkxvenom.airbeats.ui.component.VerticalFastScroller +import com.darkxvenom.airbeats.ui.menu.SelectionSongMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.utils.ItemWrapper +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.LibrarySongsViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibrarySongsScreen( + navController: NavController, + onDeselect: () -> Unit, + viewModel: LibrarySongsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val (sortType, onSortTypeChange) = rememberEnumPreference( + SongSortTypeKey, + SongSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) + + val (ytmSync) = rememberPreference(YtmSyncKey, true) + + val songs by viewModel.allSongs.collectAsState() + + var filter by rememberEnumPreference(SongFilterKey, SongFilter.LIKED) + + LaunchedEffect(Unit) { + if (ytmSync) { + when (filter) { + SongFilter.LIKED -> viewModel.syncLikedSongs() + SongFilter.LIBRARY -> viewModel.syncLibrarySongs() + else -> return@LaunchedEffect + } + } + } + + val wrappedSongs = songs.map { item -> ItemWrapper(item) }.toMutableList() + var selection by remember { + mutableStateOf(false) + } + + val lazyListState = rememberLazyListState() + + val backStackEntry by navController.currentBackStackEntryAsState() + val scrollToTop = + backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() + + LaunchedEffect(scrollToTop?.value) { + if (scrollToTop?.value == true) { + lazyListState.animateScrollToItem(0) + backStackEntry?.savedStateHandle?.set("scrollToTop", false) + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + VerticalFastScroller( + listState = lazyListState, + topContentPadding = 16.dp, + endContentPadding = 0.dp + ) + { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + item( + key = "filter", + contentType = CONTENT_TYPE_HEADER, + ) { + Row { + Spacer(Modifier.width(12.dp)) + FilterChip( + label = { Text(stringResource(R.string.songs)) }, + selected = true, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.surface), + onClick = onDeselect, + shape = RoundedCornerShape(16.dp), + leadingIcon = { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = "" + ) + }, + ) + ChipsRow( + chips = + listOf( + SongFilter.LIKED to stringResource(R.string.filter_liked), + SongFilter.LIBRARY to stringResource(R.string.filter_library), + SongFilter.DOWNLOADED to stringResource(R.string.filter_downloaded), + ), + currentValue = filter, + onValueUpdate = { + filter = it + }, + modifier = Modifier.weight(1f), + ) + } + } + + item( + key = "header", + contentType = CONTENT_TYPE_HEADER, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (selection) { + val count = wrappedSongs.count { it.isSelected } + IconButton( + onClick = { selection = false }, + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = null, + ) + } + Text( + text = pluralStringResource(R.plurals.n_song, count, count), + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + if (count == wrappedSongs.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs.forEach { it.isSelected = true } + } + }, + ) { + Icon( + painter = painterResource(if (count == wrappedSongs.size) R.drawable.deselect else R.drawable.select_all), + contentDescription = null, + ) + } + + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs.filter { it.isSelected } + .map { it.item }, + onDismiss = menuState::dismiss, + clearAction = { selection = false }, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + }, + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource( + R.plurals.n_song, + songs.size, + songs.size + ), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + ) + } + } + } + } + + itemsIndexed( + items = wrappedSongs, + key = { _, item -> item.item.song.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG }, + ) { index, songWrapper -> + SongListItem( + song = songWrapper.item, + showInLibraryIcon = true, + isActive = songWrapper.item.id == mediaMetadata?.id, + isPlaying = isPlaying, + + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + isSelected = songWrapper.isSelected && selection, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (songWrapper.item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index, + ), + ) + } + } else { + songWrapper.isSelected = !songWrapper.isSelected + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + } + wrappedSongs.forEach { + it.isSelected = false + } // Clear previous selections + songWrapper.isSelected = true // Select current item + }, + ) + .animateItem(), + ) + } + } + } + + HideOnScrollFAB( + visible = songs.isNotEmpty() == true, + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.shuffled().map { it.toMediaItem() }, + ), + ) + }, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LocalSongsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LocalSongsScreen.kt new file mode 100644 index 00000000..5ac4baa0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/library/LocalSongsScreen.kt @@ -0,0 +1,499 @@ +package com.darkxvenom.airbeats.ui.screens.library + +import android.Manifest +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.models.LocalSong +import com.darkxvenom.airbeats.viewmodels.LocalSongsViewModel + +@Composable +fun LocalSongsScreen( + navController: NavController, + viewModel: LocalSongsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val playerConnection = LocalPlayerConnection.current + + val songs by viewModel.filteredSongs.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + + // ── Permission state ────────────────────────────────────────────────────── + val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_AUDIO + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, permission) == PermissionChecker.PERMISSION_GRANTED + ) + } + var permissionDeniedPermanently by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + hasPermission = granted + permissionDeniedPermanently = !granted + if (granted) viewModel.loadSongs() + } + + LaunchedEffect(hasPermission) { + if (hasPermission) viewModel.loadSongs() + } + + val listState = rememberLazyListState() + + // ── Blur background same as other screens ───────────────────────────────── + val mediaMetadata by playerConnection?.mediaMetadata?.collectAsState() + ?: remember { mutableStateOf(null) } + + Box(modifier = Modifier.fillMaxSize()) { + + mediaMetadata?.thumbnailUrl?.let { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (isDark) Brush.verticalGradient( + listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.85f) + ) + ) else Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.25f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + MaterialTheme.colorScheme.background.copy(alpha = 0.85f) + ) + ) + ) + ) + } + + // ── Main content ────────────────────────────────────────────────────── + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + ) { + + // Header + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Local Songs", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + ) + if (songs.isNotEmpty()) { + Text( + text = "${songs.size} songs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + } + // Refresh button + if (hasPermission) { + IconButton(onClick = { viewModel.loadSongs() }) { + Icon( + painter = painterResource(R.drawable.refresh), + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + // ── Search bar ──────────────────────────────────────────────────── + if (hasPermission && songs.isNotEmpty() || searchQuery.isNotEmpty()) { + SearchBar( + query = searchQuery, + onQueryChange = viewModel::onSearchQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp) + ) + } + + // ── Permission gate / content ───────────────────────────────────── + when { + !hasPermission -> { + PermissionRequestCard( + denied = permissionDeniedPermanently, + onRequest = { permissionLauncher.launch(permission) }, + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) + } + + isLoading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp) + ) + } + } + + songs.isEmpty() && searchQuery.isNotEmpty() -> { + EmptyState( + icon = R.drawable.search, + title = "No results", + subtitle = "No songs match \"$searchQuery\"", + modifier = Modifier.fillMaxSize() + ) + } + + songs.isEmpty() -> { + EmptyState( + icon = R.drawable.music_note, + title = "No local songs found", + subtitle = "Audio files of at least 30 seconds will appear here", + modifier = Modifier.fillMaxSize() + ) + } + + else -> { + LazyColumn( + state = listState, + contentPadding = PaddingValues(bottom = 160.dp), + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed( + items = songs, + key = { _, song -> song.id } + ) { index, song -> + LocalSongItem( + song = song, + onClick = { + playerConnection?.let { pc -> + val mediaItems = songs.map { it.toMediaItem() } + pc.player.setMediaItems(mediaItems, index, 0L) + pc.player.prepare() + pc.player.play() + } + }, + ) + if (index < songs.lastIndex) { + HorizontalDivider( + modifier = Modifier.padding(start = 76.dp, end = 16.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + ) + } + } + } + } + } + } + } +} + +// ── Song row item ───────────────────────────────────────────────────────────── +@Composable +private fun LocalSongItem( + song: LocalSong, + onClick: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + // Album art + Box( + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = song.albumArtUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + error = painterResource(R.drawable.music_note), + fallback = painterResource(R.drawable.music_note), + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Title + artist + Column(modifier = Modifier.weight(1f)) { + Text( + text = song.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = song.artist, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Duration + Text( + text = song.durationText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + } +} + +// ── Search bar ──────────────────────────────────────────────────────────────── +@Composable +private fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clip(RoundedCornerShape(50.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.8f)) + .padding(horizontal = 16.dp, vertical = 10.dp) + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + decorationBox = { inner -> + Box { + if (query.isEmpty()) { + Text( + text = "Search songs, artists, albums…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + } + inner() + } + }, + modifier = Modifier.weight(1f) + ) + if (query.isNotEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)) + .clickable { onQueryChange("") }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(10.dp) + ) + } + } + } +} + +// ── Permission request card ─────────────────────────────────────────────────── +@Composable +private fun PermissionRequestCard( + denied: Boolean, + onRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.folder), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(40.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = if (denied) "Permission required" else "Access your music", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = if (denied) { + "Storage permission was denied. Please enable it in Settings to access local music." + } else { + "Allow access to your device's audio files to play local music." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onRequest, + shape = RoundedCornerShape(50.dp), + ) { + Icon( + painter = painterResource(R.drawable.folder), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = if (denied) "Open Settings" else "Allow Access") + } + } +} + +// ── Empty state ─────────────────────────────────────────────────────────────── +@Composable +private fun EmptyState( + icon: Int, + title: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier.padding(24.dp) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + modifier = Modifier.size(64.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } +} + +// ── Extension: LocalSong → MediaItem ───────────────────────────────────────── +fun LocalSong.toMediaItem(): MediaItem = + MediaItem.Builder() + .setMediaId(uri.toString()) // ✅ FIXED + .setUri(uri) + .setTag( + com.darkxvenom.airbeats.models.MediaMetadata( + id = uri.toString(), + title = title, + artists = listOf(com.darkxvenom.airbeats.models.MediaMetadata.Artist(id = null, name = artist)), + album = com.darkxvenom.airbeats.models.MediaMetadata.Album(id = "", title = album), + duration = (duration / 1000).toInt(), + thumbnailUrl = albumArtUri.toString(), + ) + ) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(artist) + .setAlbumTitle(album) + .setArtworkUri(albumArtUri) + .build() + ) + .build() \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/AutoPlaylistScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/AutoPlaylistScreen.kt new file mode 100644 index 00000000..84e0d427 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/AutoPlaylistScreen.kt @@ -0,0 +1,697 @@ +package com.darkxvenom.airbeats.ui.screens.playlist + +import androidx.compose.foundation.ExperimentalFoundationApi +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.Spacer +import androidx.compose.foundation.layout.asPaddingValues +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +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.mutableStateListOf +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.unit.sp +import androidx.compose.ui.util.fastSumBy +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AlbumThumbnailSize +import com.darkxvenom.airbeats.constants.SongSortDescendingKey +import com.darkxvenom.airbeats.constants.SongSortType +import com.darkxvenom.airbeats.constants.SongSortTypeKey +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.constants.YtmSyncKey +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.move +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.ui.component.AutoResizeText +import com.darkxvenom.airbeats.ui.component.DefaultDialog +import com.darkxvenom.airbeats.ui.component.EmptyPlaceholder +import com.darkxvenom.airbeats.ui.component.FontSizeRange +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.SortHeader +import com.darkxvenom.airbeats.ui.component.VerticalFastScroller +import com.darkxvenom.airbeats.ui.menu.SelectionSongMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.utils.ItemWrapper +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.makeTimeString +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.AutoPlaylistViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.withContext +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AutoPlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: AutoPlaylistViewModel = hiltViewModel(), +) { + + val context = LocalContext.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val playlist = + if (viewModel.playlist == "liked") stringResource(R.string.liked) else stringResource(R.string.offline) + + val songs by viewModel.likedSongs.collectAsState(null) + val mutableSongs = + remember { + mutableStateListOf() + } + + var searchQuery by remember { + mutableStateOf(TextFieldValue("")) + } + + var isSearching by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + + val (ytmSync) = rememberPreference(YtmSyncKey, true) + + val likeLength = + remember(songs) { + songs?.fastSumBy { it.song.duration } ?: 0 + } + + val playlistId = viewModel.playlist + val playlistType = when (playlistId) { + "liked" -> PlaylistType.LIKE + "downloaded" -> PlaylistType.DOWNLOAD + else -> PlaylistType.OTHER + } + + val wrappedSongs = songs?.map { item -> ItemWrapper(item) }?.toMutableList() + var selection by remember { + mutableStateOf(false) + } + + val (sortType, onSortTypeChange) = rememberEnumPreference( + SongSortTypeKey, + SongSortType.CREATE_DATE + ) + val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(Unit) { + if (ytmSync) { + withContext(Dispatchers.IO) { + if (playlistType == PlaylistType.LIKE) viewModel.syncLikedSongs() + } + } + } + + LaunchedEffect(songs) { + mutableSongs.apply { + clear() + songs?.let { addAll(it) } + } + if (songs?.isEmpty() == true) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs?.all { downloads[it.song.id]?.state == Download.STATE_COMPLETED } == true) { + Download.STATE_COMPLETED + } else if (songs?.all { + downloads[it.song.id]?.state == Download.STATE_QUEUED || + downloads[it.song.id]?.state == Download.STATE_DOWNLOADING || + downloads[it.song.id]?.state == Download.STATE_COMPLETED + } == true + ) { + Download.STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource(R.string.remove_download_playlist_confirm, playlist), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp), + ) + }, + buttons = { + TextButton( + onClick = { showRemoveDownloadDialog = false }, + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + songs!!.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false, + ) + } + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) + } + + val state = rememberLazyListState() + val database = LocalDatabase.current + val headerItems = 2 + val lazyListState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState( + lazyListState = lazyListState, + onMove = { from, to -> + if (to.index >= headerItems && from.index >= headerItems) { + mutableSongs.move(from.index - headerItems, to.index - headerItems) + } + } + ) + + val showTopBarTitle by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 + } + } + + var dismissJob: Job? by remember { mutableStateOf(null) } + + + Box( + modifier = Modifier.fillMaxSize(), + ) { + // VerticalFastScroller envuelve el LazyColumn + VerticalFastScroller( + listState = lazyListState, + topContentPadding = 16.dp, + endContentPadding = 0.dp + ) { + LazyColumn( + state = state, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + if (songs != null) { + if (songs!!.isEmpty()) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty), + ) + } + } else { + if (!isSearching) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .fillMaxWidth(), + ) { + AsyncImage( + model = songs!![0].song.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + } + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = playlist, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp), + ) + + Text( + text = + pluralStringResource( + R.plurals.n_song, + songs!!.size, + songs!!.size, + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + ) + + Text( + text = makeTimeString(likeLength * 1000L), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + ) + + Row { + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton( + onClick = { + showRemoveDownloadDialog = true + }, + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + ) + } + } + + Download.STATE_DOWNLOADING -> { + IconButton( + onClick = { + songs!!.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false, + ) + } + }, + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + ) + } + } + + else -> { + IconButton( + onClick = { + songs!!.forEach { song -> + val downloadRequest = + DownloadRequest + .Builder( + song.song.id, + song.song.id.toUri(), + ) + .setCustomCacheKey(song.song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + ) + } + } + } + + IconButton( + onClick = { + playerConnection.addToQueue( + items = songs!!.map { it.toMediaItem() }, + ) + }, + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + ) + } + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = "Auto Playlist", + items = songs!!.map { it.toMediaItem() }, + ), + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlist, + items = songs!!.shuffled() + .map { it.toMediaItem() }, + ), + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + } + + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + SongSortType.CREATE_DATE -> R.string.sort_by_create_date + SongSortType.NAME -> R.string.sort_by_name + SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + }, + modifier = Modifier.weight(1f), + ) + } + } + } + + val searchQueryStr = (searchQuery.text.trim()) + val filteredSongs = if (searchQuery.text.isEmpty()) { + wrappedSongs + } else { + wrappedSongs?.filter { + (it.item.song.title).contains(searchQueryStr, ignoreCase = true) or + (it.item.artists.joinToString("")).contains( + searchQueryStr, + ignoreCase = true + ) + } + } + if (filteredSongs != null) { + itemsIndexed( + items = filteredSongs, + key = { _, song -> song.item.id }, + ) { index, songWrapper -> + SongListItem( + song = songWrapper.item, + isActive = songWrapper.item.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + isSelected = songWrapper.isSelected && selection, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (songWrapper.item.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = playlist, + items = songs!!.map { it.toMediaItem() }, + startIndex = songs!!.indexOfFirst { it.id == songWrapper.item.id } + ), + ) + } + } else { + songWrapper.isSelected = !songWrapper.isSelected + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + } + wrappedSongs?.forEach { it.isSelected = false } + songWrapper.isSelected = true + }, + ) + .animateItem() + ) + } + } + } + } + } + + TopAppBar( + title = { + if (selection) { + val count = wrappedSongs?.count { it.isSelected } ?: 0 + Text( + text = pluralStringResource(R.plurals.n_song, count, count), + style = MaterialTheme.typography.titleLarge + ) + } else if (isSearching) { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + } else { + Text( + text = playlist, + style = MaterialTheme.typography.titleLarge + ) + } + }, + navigationIcon = { + IconButton( + onClick = { + if (isSearching) { + isSearching = false + searchQuery = TextFieldValue() + } else if (selection) { + selection = false + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painter = painterResource( + if (selection) R.drawable.close else R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (selection) { + val count = wrappedSongs?.count { it.isSelected } ?: 0 + IconButton( + onClick = { + if (count == wrappedSongs?.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs?.forEach { it.isSelected = true } + } + }, + ) { + Icon( + painter = painterResource( + if (count == wrappedSongs?.size) R.drawable.deselect else R.drawable.select_all + ), + contentDescription = null + ) + } + + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs?.filter { it.isSelected }!! + .map { it.item }, + onDismiss = menuState::dismiss, + clearAction = { selection = false }, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else if (!isSearching) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + } + ) + } +} + +enum class PlaylistType { + LIKE, DOWNLOAD, OTHER +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/LocalPlaylistScreen.kt new file mode 100644 index 00000000..05b85f16 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/LocalPlaylistScreen.kt @@ -0,0 +1,1250 @@ +package com.darkxvenom.airbeats.ui.screens.playlist + +import android.annotation.SuppressLint +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.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.WindowInsets +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberSwipeToDismissBoxState +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.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +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.unit.sp +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEachIndexed +import androidx.compose.ui.util.fastSumBy +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.LocalSyncUtils +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AlbumThumbnailSize +import com.darkxvenom.airbeats.constants.PlaylistEditLockKey +import com.darkxvenom.airbeats.constants.PlaylistSongSortDescendingKey +import com.darkxvenom.airbeats.constants.PlaylistSongSortType +import com.darkxvenom.airbeats.constants.PlaylistSongSortTypeKey +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.PlaylistSong +import com.darkxvenom.airbeats.extensions.move +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.ui.component.AutoResizeText +import com.darkxvenom.airbeats.ui.component.DefaultDialog +import com.darkxvenom.airbeats.ui.component.EmptyPlaceholder +import com.darkxvenom.airbeats.ui.component.FontSizeRange +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.SortHeader +import com.darkxvenom.airbeats.ui.component.TextFieldDialog +import com.darkxvenom.airbeats.ui.component.VerticalFastScroller +import com.darkxvenom.airbeats.ui.menu.SelectionSongMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.utils.ItemWrapper +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.deletePlaylistImage +import com.darkxvenom.airbeats.utils.getPlaylistImageUri +import com.darkxvenom.airbeats.utils.makeTimeString +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.utils.saveCustomPlaylistImage +import com.darkxvenom.airbeats.viewmodels.LocalPlaylistViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState +import java.time.LocalDateTime + + +@SuppressLint("RememberReturnType") +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun LocalPlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: LocalPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val playlist by viewModel.playlist.collectAsState() + val songs by viewModel.playlistSongs.collectAsState() + val mutableSongs = remember { mutableStateListOf() } + val playlistLength = + remember(songs) { + songs.fastSumBy { it.song.song.duration } + } + val (sortType, onSortTypeChange) = rememberEnumPreference( + PlaylistSongSortTypeKey, + PlaylistSongSortType.CUSTOM + ) + val (sortDescending, onSortDescendingChange) = rememberPreference( + PlaylistSongSortDescendingKey, + true + ) + var locked by rememberPreference(PlaylistEditLockKey, defaultValue = true) + + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var isSearching by rememberSaveable { mutableStateOf(false) } + var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + val filteredSongs = + remember(songs, query) { + if (query.text.isEmpty()) { + songs + } else { + songs.filter { song -> + song.song.song.title + .contains(query.text, ignoreCase = true) || + song.song.artists + .fastAny { it.name.contains(query.text, ignoreCase = true) } + } + } + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + if (isSearching) { + BackHandler { + isSearching = false + query = TextFieldValue() + } + } + + val wrappedSongs = filteredSongs.map { item -> ItemWrapper(item) }.toMutableList() + var selection by remember { + mutableStateOf(false) + } + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + val editable: Boolean = playlist?.playlist?.isEditable == true + + LaunchedEffect(songs) { + mutableSongs.apply { + clear() + addAll(songs) + } + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.song.id]?.state == Download.STATE_COMPLETED }) { + Download.STATE_COMPLETED + } else if (songs.all { + downloads[it.song.id]?.state == Download.STATE_QUEUED || + downloads[it.song.id]?.state == Download.STATE_DOWNLOADING || + downloads[it.song.id]?.state == Download.STATE_COMPLETED + } + ) { + Download.STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + var showEditDialog by remember { + mutableStateOf(false) + } + + if (showEditDialog) { + playlist?.playlist?.let { playlistEntity -> + TextFieldDialog( + icon = { + Icon( + painter = painterResource(R.drawable.edit), + contentDescription = null + ) + }, + title = { Text(text = stringResource(R.string.edit_playlist)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue( + playlistEntity.name, + TextRange(playlistEntity.name.length) + ), + onDone = { name -> + database.query { + update( + playlistEntity.copy( + name = name, + lastUpdateTime = LocalDateTime.now() + ) + ) + } + viewModel.viewModelScope.launch(Dispatchers.IO) { + playlistEntity.browseId?.let { YouTube.renamePlaylist(it, name) } + } + }, + ) + } + } + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource( + R.string.remove_download_playlist_confirm, + playlist?.playlist!!.name + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp), + ) + }, + buttons = { + TextButton( + onClick = { showRemoveDownloadDialog = false }, + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + if (!editable) { + database.transaction { + playlist?.id?.let { clearPlaylist(it) } + } + } + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) + } + + var showDeletePlaylistDialog by remember { + mutableStateOf(false) + } + if (showDeletePlaylistDialog) { + DefaultDialog( + onDismiss = { showDeletePlaylistDialog = false }, + content = { + Text( + text = stringResource( + R.string.delete_playlist_confirm, + playlist?.playlist!!.name + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp) + ) + }, + buttons = { + TextButton( + onClick = { + showDeletePlaylistDialog = false + } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + TextButton( + onClick = { + showDeletePlaylistDialog = false + database.query { + playlist?.let { delete(it.playlist) } + } + viewModel.viewModelScope.launch(Dispatchers.IO) { + playlist?.playlist?.browseId?.let { YouTube.deletePlaylist(it) } + } + navController.popBackStack() + } + ) { + Text(text = stringResource(android.R.string.ok)) + } + } + ) + } + + val headerItems = 2 + val lazyListState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState( + lazyListState = lazyListState, + onMove = { from, to -> + if (to.index >= headerItems && from.index >= headerItems) { + mutableSongs.move(from.index - headerItems, to.index - headerItems) + + val safeFrom = (from.index - headerItems).coerceIn(0, mutableSongs.lastIndex) + val safeTo = (to.index - headerItems).coerceIn(0, mutableSongs.lastIndex) + database.transaction { + move(viewModel.playlistId, safeFrom, safeTo) + } + } + } + ) + + val showTopBarTitle by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 + } + } + + var dismissJob: Job? by remember { mutableStateOf(null) } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + VerticalFastScroller( + listState = lazyListState, + topContentPadding = 16.dp, + endContentPadding = 0.dp + ) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime) + .asPaddingValues(), + ) { + playlist?.let { playlist -> + if (playlist.songCount == 0 && playlist.playlist.remoteSongCount == 0) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty), + ) + } + } else { + if (!isSearching) { + item { + LocalPlaylistHeader( + playlist = playlist, + songs = songs, + onShowEditDialog = { showEditDialog = true }, + onShowRemoveDownloadDialog = { + showRemoveDownloadDialog = true + }, + onshowDeletePlaylistDialog = { + showDeletePlaylistDialog = true + }, + snackbarHostState = snackbarHostState, + modifier = Modifier.animateItem() + ) + } + } + + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + PlaylistSongSortType.CUSTOM -> R.string.sort_by_custom + PlaylistSongSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSongSortType.NAME -> R.string.sort_by_name + PlaylistSongSortType.ARTIST -> R.string.sort_by_artist + PlaylistSongSortType.PLAY_TIME -> R.string.sort_by_play_time + } + }, + modifier = Modifier.weight(1f), + ) + if (editable) { + IconButton( + onClick = { locked = !locked }, + modifier = Modifier.padding(horizontal = 6.dp), + ) { + Icon( + painter = painterResource(if (locked) R.drawable.lock else R.drawable.lock_open), + contentDescription = null, + ) + } + } + } + } + } + } + + if (!selection) { + itemsIndexed( + items = if (isSearching) filteredSongs else mutableSongs, + key = { _, song -> song.map.id }, + ) { index, song -> + ReorderableItem( + state = reorderableState, + key = song.map.id, + ) { + val currentItem by rememberUpdatedState(song) + + @SuppressLint("StringFormatInvalid") + fun deleteFromPlaylist() { + database.transaction { + coroutineScope.launch { + playlist?.playlist?.browseId?.let { it1 -> + var setVideoId = getSetVideoId(currentItem.map.songId) + if (setVideoId?.setVideoId != null) { + YouTube.removeFromPlaylist( + it1, + currentItem.map.songId, + setVideoId.setVideoId!! + ) + } + } + } + move( + currentItem.map.playlistId, + currentItem.map.position, + Int.MAX_VALUE + ) + delete(currentItem.map.copy(position = Int.MAX_VALUE)) + } + dismissJob?.cancel() + dismissJob = coroutineScope.launch { + val snackbarResult = snackbarHostState.showSnackbar( + message = context.getString( + R.string.removed_song_from_playlist, + currentItem.song.song.title + ), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Short + ) + if (snackbarResult == SnackbarResult.ActionPerformed) { + database.transaction { + insert(currentItem.map.copy(position = playlistLength)) + move( + currentItem.map.playlistId, + playlistLength, + currentItem.map.position + ) + } + } + } + } + + val dismissBoxState = + rememberSwipeToDismissBoxState( + positionalThreshold = { totalDistance -> + totalDistance + }, + confirmValueChange = { dismissValue -> + if (dismissValue == SwipeToDismissBoxValue.StartToEnd || + dismissValue == SwipeToDismissBoxValue.EndToStart + ) { + deleteFromPlaylist() + } + true + }, + ) + + val content: @Composable () -> Unit = { + SongListItem( + song = song.song, + isActive = song.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song.song, + playlistSong = song, + playlistBrowseId = playlist?.playlist?.browseId, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + + if (sortType == PlaylistSongSortType.CUSTOM && !locked && !selection && !isSearching) { + IconButton( + onClick = { }, + modifier = Modifier.draggableHandle(), + ) { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = null, + ) + } + } + }, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (song.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = playlist!!.playlist.name, + items = songs.map { it.song.toMediaItem() }, + startIndex = songs.indexOfFirst { it.map.id == song.map.id }, + ), + ) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + } + wrappedSongs.forEach { it.isSelected = false } + wrappedSongs.find { it.item.map.id == song.map.id }?.isSelected = + true + }, + ) + ) + } + + if (locked || selection) { + content() + } else { + SwipeToDismissBox( + state = dismissBoxState, + backgroundContent = {}, + ) { + content() + } + } + } + } + } else { + itemsIndexed( + items = wrappedSongs, + key = { _, song -> song.item.map.id }, + ) { index, songWrapper -> + ReorderableItem( + state = reorderableState, + key = songWrapper.item.map.id, + ) { + val currentItem by rememberUpdatedState(songWrapper.item) + + @SuppressLint("StringFormatInvalid") + fun deleteFromPlaylist() { + database.transaction { + move( + currentItem.map.playlistId, + currentItem.map.position, + Int.MAX_VALUE + ) + delete(currentItem.map.copy(position = Int.MAX_VALUE)) + } + dismissJob?.cancel() + dismissJob = coroutineScope.launch { + val snackbarResult = snackbarHostState.showSnackbar( + message = context.getString( + R.string.removed_song_from_playlist, + currentItem.song.song.title + ), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Short + ) + if (snackbarResult == SnackbarResult.ActionPerformed) { + database.transaction { + insert(currentItem.map.copy(position = playlistLength)) + move( + currentItem.map.playlistId, + playlistLength, + currentItem.map.position + ) + } + } + } + } + + val dismissBoxState = + rememberSwipeToDismissBoxState( + positionalThreshold = { totalDistance -> + totalDistance + }, + confirmValueChange = { dismissValue -> + if (dismissValue == SwipeToDismissBoxValue.StartToEnd || + dismissValue == SwipeToDismissBoxValue.EndToStart + ) { + deleteFromPlaylist() + } + true + }, + ) + + val content: @Composable () -> Unit = { + SongListItem( + song = songWrapper.item.song, + isActive = songWrapper.item.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item.song, + playlistBrowseId = playlist?.playlist?.browseId, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + if (sortType == PlaylistSongSortType.CUSTOM && !locked && !selection && !isSearching) { + IconButton( + onClick = { }, + modifier = Modifier.draggableHandle(), + ) { + Icon( + painter = painterResource(R.drawable.drag_handle), + contentDescription = null, + ) + } + } + }, + isSelected = songWrapper.isSelected && selection, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (songWrapper.item.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = playlist!!.playlist.name, + items = songs.map { it.song.toMediaItem() }, + startIndex = index, + ), + ) + } + } else { + songWrapper.isSelected = + !songWrapper.isSelected + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + } + wrappedSongs.forEach { it.isSelected = false } + songWrapper.isSelected = true + }, + ), + ) + } + + if (locked || !editable) { + content() + } else { + SwipeToDismissBox( + state = dismissBoxState, + backgroundContent = {}, + ) { + content() + } + } + } + } + } + } + } + + TopAppBar( + title = { + if (selection) { + val count = wrappedSongs.count { it.isSelected } + Text( + text = pluralStringResource(R.plurals.n_song, count, count), + style = MaterialTheme.typography.titleLarge + ) + } else if (isSearching) { + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + } else if (showTopBarTitle) { + Text(playlist?.playlist?.name.orEmpty()) + } + }, + navigationIcon = { + IconButton( + onClick = { + if (isSearching) { + isSearching = false + query = TextFieldValue() + } else if (selection) { + selection = false + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painter = painterResource( + if (selection) R.drawable.close else R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (selection) { + val count = wrappedSongs.count { it.isSelected } + IconButton( + onClick = { + if (count == wrappedSongs.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs.forEach { it.isSelected = true } + } + }, + ) { + Icon( + painter = painterResource( + if (count == wrappedSongs.size) R.drawable.deselect else R.drawable.select_all + ), + contentDescription = null + ) + } + + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs.filter { it.isSelected } + .map { it.item.song }, + songPosition = wrappedSongs.filter { it.isSelected } + .map { it.item.map }, + onDismiss = menuState::dismiss, + clearAction = { + selection = false + wrappedSongs.clear() + } + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else if (!isSearching) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + } + ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime)) + .align(Alignment.BottomCenter), + ) + } +} + +@Composable +fun LocalPlaylistHeader( + playlist: Playlist, + songs: List, + onShowEditDialog: () -> Unit, + onShowRemoveDownloadDialog: () -> Unit, + onshowDeletePlaylistDialog: () -> Unit, + snackbarHostState: SnackbarHostState, + modifier: Modifier, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val context = LocalContext.current + val database = LocalDatabase.current + val syncUtils = LocalSyncUtils.current + val scope = rememberCoroutineScope() + + val playlistLength = remember(songs) { + songs.fastSumBy { it.song.song.duration } + } + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableIntStateOf(Download.STATE_STOPPED) + } + + val liked = playlist.playlist.bookmarkedAt != null + val editable: Boolean = playlist.playlist.isEditable == true + + var customThumbnailUri by rememberSaveable { + mutableStateOf(getPlaylistImageUri(context, playlist.playlist.id)) + } + + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { + customThumbnailUri = it + saveCustomPlaylistImage(context, playlist.playlist.id, it) + } + } + + LaunchedEffect(songs) { + if (songs.isEmpty()) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs.all { downloads[it.song.id]?.state == Download.STATE_COMPLETED }) { + Download.STATE_COMPLETED + } else if (songs.all { + downloads[it.song.id]?.state == Download.STATE_QUEUED || + downloads[it.song.id]?.state == Download.STATE_DOWNLOADING || + downloads[it.song.id]?.state == Download.STATE_COMPLETED + } + ) { + Download.STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier.padding(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val imageModifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .clickable(enabled = editable) { + if (editable) imagePickerLauncher.launch("image/*") + } + + Box(modifier = imageModifier) { + var showEditButtons by remember { mutableStateOf(false) } + + val longPressModifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onLongPress = { showEditButtons = !showEditButtons } + ) + } + + when { + customThumbnailUri != null -> { + AsyncImage( + model = customThumbnailUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .then(longPressModifier) + ) + } + + playlist.thumbnails.size == 1 -> { + AsyncImage( + model = playlist.thumbnails[0], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .then(longPressModifier) + ) + } + + playlist.thumbnails.size > 1 -> { + Box( + modifier = Modifier + .fillMaxSize() + .then(longPressModifier) + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd, + ).fastForEachIndexed { index, alignment -> + AsyncImage( + model = playlist.thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(AlbumThumbnailSize / 2) + ) + } + } + } + + else -> { + Icon( + painter = painterResource(R.drawable.image), + contentDescription = stringResource(R.string.add_thumbnail), + modifier = Modifier + .size(48.dp) + .align(Alignment.Center) + .then(longPressModifier), + tint = if (editable) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + + if (editable && showEditButtons) { + Row( + modifier = Modifier + .align(Alignment.BottomEnd) + .clip(RoundedCornerShape(54.dp)) + .background(MaterialTheme.colorScheme.primary) + .padding(4.dp), + + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { imagePickerLauncher.launch("image/*") }, + modifier = Modifier.size(24.dp) + ) { + Icon( + painter = painterResource(R.drawable.edit), + contentDescription = stringResource(R.string.edit_thumbnail), + tint = MaterialTheme.colorScheme.surface + ) + } + if (customThumbnailUri != null) { + IconButton( + onClick = { + deletePlaylistImage(context, playlist.playlist.id) + customThumbnailUri = null + showEditButtons = + false + }, + modifier = Modifier.size(24.dp) + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = stringResource(R.string.remove_thumbnail), + tint = MaterialTheme.colorScheme.surface + ) + } + } + } + } + } + + Column { + AutoResizeText( + text = playlist.playlist.name, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp), + ) + + Text( + text = pluralStringResource( + R.plurals.n_song, + playlist.songCount, + playlist.songCount + ), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = makeTimeString(playlistLength * 1000L), + style = MaterialTheme.typography.titleMedium, + ) + + Row { + if (editable) { + IconButton(onClick = onshowDeletePlaylistDialog) { + Icon( + painter = painterResource(R.drawable.delete), + contentDescription = null + ) + } + IconButton(onClick = onShowEditDialog) { + Icon( + painter = painterResource(R.drawable.edit), + contentDescription = null + ) + } + } else { + IconButton( + onClick = { + database.transaction { + update(playlist.playlist.toggleLike()) + } + } + ) { + Icon( + painter = painterResource( + if (liked) R.drawable.favorite else R.drawable.favorite_border + ), + contentDescription = null, + tint = if (liked) MaterialTheme.colorScheme.error else LocalContentColor.current + ) + } + } + + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton(onClick = onShowRemoveDownloadDialog) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null + ) + } + } + + Download.STATE_DOWNLOADING -> { + IconButton(onClick = { + songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false + ) + } + }) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp) + ) + } + } + + else -> { + IconButton(onClick = { + songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder( + song.song.id, song.song.id.toUri() + ) + .setCustomCacheKey(song.song.id) + .setData(song.song.song.title.toByteArray()) + .build() + + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + }) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + } + } + + IconButton(onClick = { + playerConnection.addToQueue( + items = songs.map { it.song.toMediaItem() } + ) + }) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null + ) + } + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.map { it.song.toMediaItem() }, + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon(painter = painterResource(R.drawable.play), contentDescription = null) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.shuffled().map { it.song.toMediaItem() }, + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon(painter = painterResource(R.drawable.shuffle), contentDescription = null) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/OnlinePlaylistScreen.kt new file mode 100644 index 00000000..1606236a --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -0,0 +1,640 @@ +package com.darkxvenom.airbeats.ui.screens.playlist + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.ScrollableDefaults +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.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +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.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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +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.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastAny +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AlbumThumbnailSize +import com.darkxvenom.airbeats.constants.HideExplicitKey +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.db.entities.PlaylistSongMap +import com.darkxvenom.airbeats.extensions.metadata +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.AutoResizeText +import com.darkxvenom.airbeats.ui.component.FontSizeRange +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.VerticalFastScroller +import com.darkxvenom.airbeats.ui.component.YouTubeListItem +import com.darkxvenom.airbeats.ui.component.shimmer.ButtonPlaceholder +import com.darkxvenom.airbeats.ui.component.shimmer.ListItemPlaceHolder +import com.darkxvenom.airbeats.ui.component.shimmer.ShimmerHost +import com.darkxvenom.airbeats.ui.component.shimmer.TextPlaceholder +import com.darkxvenom.airbeats.ui.menu.SelectionMediaMetadataMenu +import com.darkxvenom.airbeats.ui.menu.YouTubePlaylistMenu +import com.darkxvenom.airbeats.ui.menu.YouTubeSongMenu +import com.darkxvenom.airbeats.ui.utils.ItemWrapper +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.OnlinePlaylistViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun OnlinePlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: OnlinePlaylistViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val database = LocalDatabase.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val playlist by viewModel.playlist.collectAsState() + val songs by viewModel.playlistSongs.collectAsState() + val dbPlaylist by viewModel.dbPlaylist.collectAsState() + + var selection by remember { + mutableStateOf(false) + } + val hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) + + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var isSearching by rememberSaveable { mutableStateOf(false) } + var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + val filteredSongs = + remember(songs, query) { + if (query.text.isEmpty()) { + songs.mapIndexed { index, song -> index to song } + } else { + songs + .mapIndexed { index, song -> index to song } + .filter { (_, song) -> + song.title.contains(query.text, ignoreCase = true) || + song.artists.fastAny { + it.name.contains( + query.text, + ignoreCase = true + ) + } + } + } + } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + if (isSearching) { + BackHandler { + isSearching = false + query = TextFieldValue() + } + } + + + + + + + + + LaunchedEffect(playlist?.id) { + // Restaurar posición si cambias de playlist + if (playlist?.id != null) { + lazyListState.scrollToItem(0) + } + } + + val showTopBarTitle by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 + } + } + + // Calcular el padding del contenido considerando WindowInsets + val contentPadding = LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime) + .asPaddingValues() + val wrappedSongs = filteredSongs.map { item -> ItemWrapper(item) }.toMutableList() + + Box( + modifier = Modifier.fillMaxSize(), + ) { + VerticalFastScroller( + listState = lazyListState, + topContentPadding = contentPadding.calculateTopPadding(), + endContentPadding = 0.dp + ) { + LazyColumn( + state = lazyListState, + contentPadding = contentPadding, + flingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled = true, + ) { + playlist.let { playlist -> + if (playlist != null) { + if (!isSearching) { + item { + Column( + modifier = + Modifier + .padding(12.dp) + .animateItem(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .fillMaxWidth(), + ) { + AsyncImage( + model = playlist.thumbnail, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + } + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = playlist.title, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp), + ) + + playlist.author?.let { artist -> + Text( + buildAnnotatedString { + withStyle( + style = + MaterialTheme.typography.titleMedium + .copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground, + ).toSpanStyle(), + ) { + if (artist.id != null) { + val link = + LinkAnnotation.Clickable(artist.id!!) { + navController.navigate("artist/${artist.id!!}") + } + withLink(link) { + append(artist.name) + } + } else { + append(artist.name) + } + } + }, + ) + } + + playlist.songCountText?.let { songCountText -> + Text( + text = songCountText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + ) + } + + Row { + if (playlist.id != "LM") { + IconButton( + onClick = { + if (dbPlaylist?.playlist == null) { + database.transaction { + val playlistEntity = + PlaylistEntity( + name = playlist.title, + browseId = playlist.id, + isEditable = playlist.isEditable, + playEndpointParams = playlist.playEndpoint?.params, + shuffleEndpointParams = playlist.shuffleEndpoint?.params, + radioEndpointParams = playlist.radioEndpoint?.params + ).toggleLike() + insert(playlistEntity) + songs.map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { index, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlistEntity.id, + position = index + ) + } + .forEach(::insert) + } + } else { + database.transaction { + update(dbPlaylist!!.playlist.toggleLike()) + } + } + } + ) { + Icon( + painter = painterResource( + if (dbPlaylist?.playlist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border + ), + contentDescription = null, + tint = if (dbPlaylist?.playlist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current + ) + } + } + + IconButton( + onClick = { + menuState.show { + YouTubePlaylistMenu( + playlist = playlist, + songs = songs, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + selectAction = { selection = true }, + canSelect = true, + onRefresh = viewModel::refresh, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + playlist.shuffleEndpoint?.let { shuffleEndpoint -> + Button( + onClick = { + playerConnection.playQueue( + YouTubeQueue( + shuffleEndpoint + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + + playlist.radioEndpoint?.let { radioEndpoint -> + OutlinedButton( + onClick = { + playerConnection.playQueue( + YouTubeQueue( + radioEndpoint + ) + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.radio)) + } + } + } + } + } + } + + items( + items = wrappedSongs, + ) { song -> + YouTubeListItem( + item = song.item.second, + isActive = mediaMetadata?.id == song.item.second.id, + isPlaying = isPlaying, + isSelected = song.isSelected && selection, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song.item.second, + playlist = playlist, + navController = navController, + onDismiss = menuState::dismiss, + onRefresh = viewModel::refresh, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .combinedClickable( + enabled = !hideExplicit || !song.item.second.explicit, + onClick = { + if (!selection) { + if (song.item.second.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.service.getAutomix( + playlistId = playlist.id + ) + playerConnection.playQueue( + YouTubeQueue( + song.item.second.endpoint + ?: WatchEndpoint( + videoId = + song.item.second + .id, + ), + song.item.second.toMediaMetadata(), + ), + ) + } + } else { + song.isSelected = !song.isSelected + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + } + wrappedSongs.forEach { it.isSelected = false } + song.isSelected = true + }, + ) + .alpha(if (hideExplicit && song.item.second.explicit) 0.3f else 1f) + .animateItem(), + ) + } + } else { + item { + ShimmerHost { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer( + modifier = + Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(MaterialTheme.colorScheme.onSurface), + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } + + Spacer(Modifier.padding(8.dp)) + + Row { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + } + } + + TopAppBar( + title = { + if (selection) { + val count = wrappedSongs.count { it.isSelected } + Text( + text = pluralStringResource(R.plurals.n_song, count, count), + style = MaterialTheme.typography.titleLarge + ) + } else if (isSearching) { + TextField( + value = query, + onValueChange = { query = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + } else if (showTopBarTitle) { + Text(playlist?.title.orEmpty()) + } + }, + navigationIcon = { + IconButton( + onClick = { + if (isSearching) { + isSearching = false + query = TextFieldValue() + } else if (selection) { + selection = false + } else { + navController.navigateUp() + } + }, + ) { + Icon( + painter = painterResource( + if (selection) R.drawable.close else R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (selection) { + val count = wrappedSongs.count { it.isSelected } + IconButton( + onClick = { + if (count == wrappedSongs.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs.forEach { it.isSelected = true } + } + }, + ) { + Icon( + painter = painterResource( + if (count == wrappedSongs.size) R.drawable.deselect else R.drawable.select_all + ), + contentDescription = null + ) + } + IconButton( + onClick = { + menuState.show { + SelectionMediaMetadataMenu( + songSelection = wrappedSongs.filter { it.isSelected } + .map { it.item.second.toMediaItem().metadata!! }, + onDismiss = menuState::dismiss, + clearAction = { selection = false }, + currentItems = emptyList() + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else { + if (!isSearching) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + } + } + ) + + SnackbarHost( + hostState = snackbarHostState, + modifier = + Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime)) + .align(Alignment.BottomCenter), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/TopPlaylistScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/TopPlaylistScreen.kt new file mode 100644 index 00000000..4240b612 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/playlist/TopPlaylistScreen.kt @@ -0,0 +1,632 @@ +package com.darkxvenom.airbeats.ui.screens.playlist + +import androidx.compose.foundation.ExperimentalFoundationApi +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.Spacer +import androidx.compose.foundation.layout.asPaddingValues +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.unit.sp +import androidx.compose.ui.util.fastSumBy +import androidx.core.net.toUri +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.LocalDownloadUtil +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AlbumThumbnailSize +import com.darkxvenom.airbeats.constants.MyTopFilter +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.playback.ExoDownloadService +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.ui.component.AutoResizeText +import com.darkxvenom.airbeats.ui.component.DefaultDialog +import com.darkxvenom.airbeats.ui.component.EmptyPlaceholder +import com.darkxvenom.airbeats.ui.component.FontSizeRange +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.component.SortHeader +import com.darkxvenom.airbeats.ui.menu.SelectionSongMenu +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.ui.utils.ItemWrapper +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.makeTimeString +import com.darkxvenom.airbeats.viewmodels.TopPlaylistViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun TopPlaylistScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: TopPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val haptic = LocalHapticFeedback.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val maxSize = viewModel.top + + val songs by viewModel.topSongs.collectAsState(null) + val mutableSongs = + remember { + mutableStateListOf() + } + + val likeLength = + remember(songs) { + songs?.fastSumBy { it.song.duration } ?: 0 + } + + val wrappedSongs = songs?.map { item -> ItemWrapper(item) }?.toMutableList() + + var searchQuery by remember { + mutableStateOf(TextFieldValue("")) + } + var isSearching by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + LaunchedEffect(isSearching) { + if (isSearching) { + focusRequester.requestFocus() + } + } + var selection by remember { + mutableStateOf(false) + } + + val sortType by viewModel.topPeriod.collectAsState() + val name = stringResource(R.string.my_top) + " $maxSize" + + val downloadUtil = LocalDownloadUtil.current + var downloadState by remember { + mutableStateOf(Download.STATE_STOPPED) + } + + LaunchedEffect(songs) { + mutableSongs.apply { + clear() + songs?.let { addAll(it) } + } + if (songs?.isEmpty() == true) return@LaunchedEffect + downloadUtil.downloads.collect { downloads -> + downloadState = + if (songs?.all { downloads[it.song.id]?.state == Download.STATE_COMPLETED } == true) { + Download.STATE_COMPLETED + } else if (songs?.all { + downloads[it.song.id]?.state == Download.STATE_QUEUED || + downloads[it.song.id]?.state == Download.STATE_DOWNLOADING || + downloads[it.song.id]?.state == Download.STATE_COMPLETED + } == true + ) { + Download.STATE_DOWNLOADING + } else { + Download.STATE_STOPPED + } + } + } + + var showRemoveDownloadDialog by remember { + mutableStateOf(false) + } + + if (showRemoveDownloadDialog) { + DefaultDialog( + onDismiss = { showRemoveDownloadDialog = false }, + content = { + Text( + text = stringResource(R.string.remove_download_playlist_confirm, name), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 18.dp), + ) + }, + buttons = { + TextButton( + onClick = { showRemoveDownloadDialog = false }, + ) { + Text(text = stringResource(android.R.string.cancel)) + } + + TextButton( + onClick = { + showRemoveDownloadDialog = false + songs!!.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false, + ) + } + }, + ) { + Text(text = stringResource(android.R.string.ok)) + } + }, + ) + } + + val state = rememberLazyListState() + + Box( + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + state = state, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), + ) { + if (songs != null) { + if (songs!!.isEmpty()) { + item { + EmptyPlaceholder( + icon = R.drawable.music_note, + text = stringResource(R.string.playlist_is_empty), + ) + } + } else { + if (!isSearching) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .fillMaxWidth(), + ) { + AsyncImage( + model = songs!![0].song.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)), + ) + } + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp), + ) + + Text( + text = + pluralStringResource( + R.plurals.n_song, + songs!!.size, + songs!!.size, + ), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + ) + + Text( + text = makeTimeString(likeLength * 1000L), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + ) + + Row { + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton( + onClick = { + showRemoveDownloadDialog = true + }, + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null, + ) + } + } + + Download.STATE_DOWNLOADING -> { + IconButton( + onClick = { + songs!!.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.song.id, + false, + ) + } + }, + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + ) + } + } + + else -> { + IconButton( + onClick = { + songs!!.forEach { song -> + val downloadRequest = + DownloadRequest + .Builder( + song.song.id, + song.song.id.toUri(), + ) + .setCustomCacheKey(song.song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + ) + } + } + } + + IconButton( + onClick = { + playerConnection.addToQueue( + items = songs!!.map { it.toMediaItem() }, + ) + }, + ) { + Icon( + painter = painterResource(R.drawable.queue_music), + contentDescription = null, + ) + } + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = "Auto Playlist", + items = songs!!.map { it.toMediaItem() }, + ), + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.play)) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue( + ListQueue( + title = name, + items = songs!!.shuffled() + .map { it.toMediaItem() }, + ), + ) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f), + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize), + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) + } + } + } + } + } + + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp), + ) { + SortHeader( + sortType = sortType, + sortDescending = false, + onSortTypeChange = { + viewModel.topPeriod.value = it + }, + onSortDescendingChange = {}, + sortTypeText = { sortType -> + when (sortType) { + MyTopFilter.ALL_TIME -> R.string.all_time + MyTopFilter.DAY -> R.string.past_24_hours + MyTopFilter.WEEK -> R.string.past_week + MyTopFilter.MONTH -> R.string.past_month + MyTopFilter.YEAR -> R.string.past_year + } + }, + modifier = Modifier.weight(1f), + showDescending = false, + ) + } + } + } + + val searchQueryStr = (searchQuery.text.trim()) + val filteredSongs = if (searchQueryStr.isEmpty()) { + wrappedSongs + } else { + wrappedSongs?.filter { + (it.item.song.title).contains(searchQueryStr, ignoreCase = true) or + (it.item.artists.joinToString("").trim()).contains( + searchQueryStr, + ignoreCase = true + ) + } + } + + if (filteredSongs != null) { + itemsIndexed( + items = filteredSongs, + key = { _, song -> song.item.id }, + ) { index, songWrapper -> + SongListItem( + song = songWrapper.item, + albumIndex = index + 1, + isActive = songWrapper.item.song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = songWrapper.item, + navController = navController, + onDismiss = menuState::dismiss, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + isSelected = songWrapper.isSelected && selection, + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = { + if (!selection) { + if (songWrapper.item.song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = name, + items = filteredSongs.map { it.item.toMediaItem() }, + startIndex = index, + ), + ) + } + } else { + songWrapper.isSelected = !songWrapper.isSelected + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + if (!selection) { + selection = true + } + wrappedSongs?.forEach { it.isSelected = false } + songWrapper.isSelected = true + }, + ) + .animateItem() + ) + } + } + } + } + + TopAppBar( + title = { + if (selection) { + val count = wrappedSongs?.count { it.isSelected } ?: 0 + Text( + text = pluralStringResource(R.plurals.n_song, count, count), + style = MaterialTheme.typography.titleLarge + ) + } else if (isSearching) { + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + text = stringResource(R.string.search), + style = MaterialTheme.typography.titleLarge + ) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + } else { + Text( + text = name, + ) + } + }, + navigationIcon = { + IconButton( + onClick = { + if (isSearching) { + isSearching = false + searchQuery = TextFieldValue() + } else if (selection) { + selection = false + } else { + navController.navigateUp() + } + }, + onLongClick = { + if (!isSearching) { + navController.backToMain() + } + } + ) { + Icon( + painter = painterResource( + if (selection) R.drawable.close else R.drawable.arrow_back + ), + contentDescription = null + ) + } + }, + actions = { + if (selection) { + val count = wrappedSongs?.count { it.isSelected } ?: 0 + IconButton( + onClick = { + if (count == wrappedSongs?.size) { + wrappedSongs.forEach { it.isSelected = false } + } else { + wrappedSongs?.forEach { it.isSelected = true } + } + }, + ) { + Icon( + painter = painterResource( + if (count == wrappedSongs?.size) R.drawable.deselect else R.drawable.select_all + ), + contentDescription = null + ) + } + + IconButton( + onClick = { + menuState.show { + SelectionSongMenu( + songSelection = wrappedSongs?.filter { it.isSelected }!! + .map { it.item }, + onDismiss = menuState::dismiss, + clearAction = { selection = false }, + ) + } + }, + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + } else if (!isSearching) { + IconButton( + onClick = { isSearching = true } + ) { + Icon( + painter = painterResource(R.drawable.search), + contentDescription = null + ) + } + } + } + ) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/LocalSearchScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/LocalSearchScreen.kt new file mode 100644 index 00000000..1bda9f25 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/LocalSearchScreen.kt @@ -0,0 +1,263 @@ +package com.darkxvenom.airbeats.ui.screens.search + +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.Column +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.CONTENT_TYPE_LIST +import com.darkxvenom.airbeats.constants.ListItemHeight +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.db.entities.Artist +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.toMediaItem +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.playback.queues.ListQueue +import com.darkxvenom.airbeats.ui.component.AlbumListItem +import com.darkxvenom.airbeats.ui.component.ArtistListItem +import com.darkxvenom.airbeats.ui.component.ChipsRow +import com.darkxvenom.airbeats.ui.component.EmptyPlaceholder +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.PlaylistListItem +import com.darkxvenom.airbeats.ui.component.SongListItem +import com.darkxvenom.airbeats.ui.menu.SongMenu +import com.darkxvenom.airbeats.viewmodels.LocalFilter +import com.darkxvenom.airbeats.viewmodels.LocalSearchViewModel +import kotlinx.coroutines.flow.drop + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LocalSearchScreen( + query: String, + navController: NavController, + onDismiss: () -> Unit, + isFromCache: Boolean = false, + pureBlack: Boolean, + viewModel: LocalSearchViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val keyboardController = LocalSoftwareKeyboardController.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val searchFilter by viewModel.filter.collectAsState() + val result by viewModel.result.collectAsState() + + val lazyListState = rememberLazyListState() + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset } + .drop(1) + .collect { + keyboardController?.hide() + } + } + + LaunchedEffect(query) { + viewModel.query.value = query + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(if (pureBlack) Color.Black else MaterialTheme.colorScheme.background) + ) { + ChipsRow( + chips = listOf( + LocalFilter.ALL to stringResource(R.string.filter_all), + LocalFilter.SONG to stringResource(R.string.filter_songs), + LocalFilter.ALBUM to stringResource(R.string.filter_albums), + LocalFilter.ARTIST to stringResource(R.string.filter_artists), + LocalFilter.PLAYLIST to stringResource(R.string.filter_playlists), + ), + currentValue = searchFilter, + onValueUpdate = { viewModel.filter.value = it }, + ) + + LazyColumn( + state = lazyListState, + modifier = Modifier.weight(1f), + ) { + result.map.forEach { (filter, items) -> + if (result.filter == LocalFilter.ALL) { + item(key = filter) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight) + .clickable { viewModel.filter.value = filter } + .padding(start = 12.dp, end = 18.dp), + ) { + Text( + text = stringResource( + when (filter) { + LocalFilter.SONG -> R.string.filter_songs + LocalFilter.ALBUM -> R.string.filter_albums + LocalFilter.ARTIST -> R.string.filter_artists + LocalFilter.PLAYLIST -> R.string.filter_playlists + LocalFilter.ALL -> error("") + } + ), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + + Icon( + painter = painterResource(R.drawable.navigate_next), + contentDescription = null, + ) + } + } + } + + items( + items = items, + key = { it.id }, + contentType = { CONTENT_TYPE_LIST }, + ) { item -> + when (item) { + is Song -> SongListItem( + song = item, + showInLibraryIcon = true, + isActive = item.id == mediaMetadata?.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = item, + navController = navController, + onDismiss = { + onDismiss() + menuState.dismiss() + }, + isFromCache = isFromCache + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = Modifier + .combinedClickable( + onClick = { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + val songs = result.map + .getOrDefault(LocalFilter.SONG, emptyList()) + .filterIsInstance() + .map { it.toMediaItem() } + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_searched_songs), + items = songs, + startIndex = songs.indexOfFirst { it.mediaId == item.id }, + ) + ) + } + }, + onLongClick = { + menuState.show { + SongMenu( + originalSong = item, + navController = navController, + onDismiss = { + onDismiss() + menuState.dismiss() + }, + isFromCache = isFromCache + ) + } + } + ) + .animateItem(), + ) + + is Album -> AlbumListItem( + album = item, + isActive = item.id == mediaMetadata?.album?.id, + isPlaying = isPlaying, + modifier = Modifier + .clickable { + onDismiss() + navController.navigate("album/${item.id}") + } + .animateItem(), + ) + + is Artist -> ArtistListItem( + artist = item, + modifier = Modifier + .clickable { + onDismiss() + navController.navigate("artist/${item.id}") + } + .animateItem(), + ) + + is Playlist -> PlaylistListItem( + playlist = item, + modifier = Modifier + .clickable { + onDismiss() + navController.navigate("local_playlist/${item.id}") + } + .animateItem(), + ) + } + } + } + + if (result.query.isNotEmpty() && result.map.isEmpty()) { + item(key = "no_result") { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found), + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/OnlineSearchResult.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/OnlineSearchResult.kt new file mode 100644 index 00000000..f9cd044e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/OnlineSearchResult.kt @@ -0,0 +1,291 @@ +package com.darkxvenom.airbeats.ui.screens.search + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +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.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import kotlinx.coroutines.launch + +import com.darkxvenom.airbeats.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM +import com.darkxvenom.airbeats.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST +import com.darkxvenom.airbeats.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_PLAYLIST +import com.darkxvenom.airbeats.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PLAYLIST +import com.darkxvenom.airbeats.innertube.YouTube.SearchFilter.Companion.FILTER_SONG +import com.darkxvenom.airbeats.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO + +import com.darkxvenom.airbeats.innertube.models.* +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AppBarHeight +import com.darkxvenom.airbeats.constants.SearchFilterHeight +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.* +import com.darkxvenom.airbeats.ui.component.shimmer.* +import com.darkxvenom.airbeats.ui.menu.* +import com.darkxvenom.airbeats.viewmodels.OnlineSearchViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun OnlineSearchResult( + navController: NavController, + viewModel: OnlineSearchViewModel = hiltViewModel(), +) { + + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val haptic = LocalHapticFeedback.current + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + + val searchFilter by viewModel.filter.collectAsState() + val searchSummary = viewModel.summaryPage + val itemsPage by remember(searchFilter) { + derivedStateOf { + searchFilter?.value?.let { + viewModel.viewStateMap[it] + } + } + } + + // 🌗 THEME AWARE OVERLAY + val isDarkTheme = MaterialTheme.colorScheme.background.luminance() < 0.5f + + val gradientColors = + if (isDarkTheme) { + listOf( + Color.Black.copy(alpha = 0.25f), + Color.Black.copy(alpha = 0.55f), + Color.Black.copy(alpha = 0.85f) + ) + } else { + listOf( + Color.White.copy(alpha = 0.25f), + Color.White.copy(alpha = 0.45f), + Color.White.copy(alpha = 0.65f) + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + + // 🔥 BLUR BACKGROUND + mediaMetadata?.thumbnailUrl?.let { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient(gradientColors) + ) + ) + } + + // 📜 ORIGINAL CONTENT (UNCHANGED) + LazyColumn( + state = lazyListState, + contentPadding = + LocalPlayerAwareWindowInsets.current + .add(WindowInsets(top = SearchFilterHeight)) + .add(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) + .asPaddingValues(), + ) { + + val ytItemContent: @Composable LazyItemScope.(YTItem) -> Unit = { item -> + + val longClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> + YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is AlbumItem -> + YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is ArtistItem -> + YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss, + ) + + is PlaylistItem -> + YouTubePlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + } + } + + YouTubeListItem( + item = item, + isActive = + when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + trailingContent = { + IconButton(onClick = longClick) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null, + ) + } + }, + modifier = + Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = item.id), + item.toMediaMetadata() + ) + ) + } + } + + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") + } + }, + onLongClick = longClick, + ) + .animateItem(), + ) + } + + if (searchFilter == null) { + searchSummary?.summaries?.forEach { summary -> + item { NavigationTitle(summary.title) } + + items( + items = summary.items, + key = { "${summary.title}/${it.id}" }, + itemContent = ytItemContent, + ) + } + + if (searchSummary?.summaries?.isEmpty() == true) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found), + ) + } + } + } else { + items( + items = itemsPage?.items.orEmpty().distinctBy { it.id }, + key = { it.id }, + itemContent = ytItemContent, + ) + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost { + repeat(3) { + ListItemPlaceHolder() + } + } + } + } + + if (itemsPage?.items?.isEmpty() == true) { + item { + EmptyPlaceholder( + icon = R.drawable.search, + text = stringResource(R.string.no_results_found), + ) + } + } + } + } + + // 🔥 FILTER BAR (BLENDS IN LIGHT MODE) + ChipsRow( + chips = + listOf( + null to stringResource(R.string.filter_all), + FILTER_SONG to stringResource(R.string.filter_songs), + FILTER_VIDEO to stringResource(R.string.filter_videos), + FILTER_ALBUM to stringResource(R.string.filter_albums), + FILTER_ARTIST to stringResource(R.string.filter_artists), + FILTER_COMMUNITY_PLAYLIST to stringResource(R.string.filter_community_playlists), + FILTER_FEATURED_PLAYLIST to stringResource(R.string.filter_featured_playlists), + ), + currentValue = searchFilter, + onValueUpdate = { + if (viewModel.filter.value != it) { + viewModel.filter.value = it + } + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + }, + modifier = + Modifier + .background(Color.Transparent) + .windowInsetsPadding( + WindowInsets.safeDrawing + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .add(WindowInsets(top = AppBarHeight)) + ) + .fillMaxWidth() + ) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/OnlineSearchScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/OnlineSearchScreen.kt new file mode 100644 index 00000000..87432f67 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/search/OnlineSearchScreen.kt @@ -0,0 +1,373 @@ +package com.darkxvenom.airbeats.ui.screens.search + +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.* +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.systemBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.innertube.models.* +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.SuggestionItemHeight +import com.darkxvenom.airbeats.extensions.togglePlayPause +import com.darkxvenom.airbeats.models.toMediaMetadata +import com.darkxvenom.airbeats.playback.queues.YouTubeQueue +import com.darkxvenom.airbeats.ui.component.LocalMenuState +import com.darkxvenom.airbeats.ui.component.YouTubeListItem +import com.darkxvenom.airbeats.ui.menu.* +import com.darkxvenom.airbeats.viewmodels.OnlineSearchSuggestionViewModel +import kotlinx.coroutines.flow.drop + +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalComposeUiApi::class, + ExperimentalMaterial3Api::class +) +@Composable +fun OnlineSearchScreen( + query: String, + onQueryChange: (TextFieldValue) -> Unit, + navController: NavController, + onSearch: (String) -> Unit, + onDismiss: () -> Unit, + viewModel: OnlineSearchSuggestionViewModel = hiltViewModel(), +) { + + val database = LocalDatabase.current + val keyboardController = LocalSoftwareKeyboardController.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val haptic = LocalHapticFeedback.current + + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val coroutineScope = rememberCoroutineScope() + val viewState by viewModel.viewState.collectAsState() + + val lazyListState = rememberLazyListState() + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset } + .drop(1) + .collect { keyboardController?.hide() } + } + + LaunchedEffect(query) { + viewModel.query.value = query + } + + // 🌗 Light / Dark safe gradient + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + + val gradientColors = + if (isDark) { + listOf( + Color.Black.copy(alpha = 0.25f), + Color.Black.copy(alpha = 0.55f), + Color.Black.copy(alpha = 0.85f) + ) + } else { + listOf( + Color.White.copy(alpha = 0.25f), + Color.White.copy(alpha = 0.55f), + Color.White.copy(alpha = 0.75f) + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + + // 🔥 Blur background using current playing artwork + mediaMetadata?.thumbnailUrl?.let { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient(gradientColors) + ) + ) + } + + // 📜 ORIGINAL CONTENT (UNCHANGED) + LazyColumn( + state = lazyListState, + contentPadding = + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom) + .asPaddingValues(), + ) { + + item { + Text( + text = stringResource(R.string.SearchHistory), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + items(viewState.history, key = { it.query }) { history -> + SuggestionItem( + query = history.query, + online = false, + onClick = { + onSearch(history.query) + onDismiss() + }, + onDelete = { + database.query { delete(history) } + }, + onFillTextField = { + onQueryChange( + TextFieldValue( + text = history.query, + selection = TextRange(history.query.length), + ), + ) + }, + modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), + ) + } + + if (viewState.suggestions.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.Sujestions), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + + items(viewState.suggestions, key = { it }) { query -> + SuggestionItem( + query = query, + online = true, + onClick = { + onSearch(query) + onDismiss() + }, + onFillTextField = { + onQueryChange( + TextFieldValue( + text = query, + selection = TextRange(query.length), + ), + ) + }, + modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null), + ) + } + + if (viewState.items.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.SearchResutls), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } + + items(viewState.items, key = { it.id }) { item -> + YouTubeListItem( + item = item, + isActive = + when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + modifier = + Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + YouTubeQueue( + WatchEndpoint(videoId = item.id), + item.toMediaMetadata() + ), + ) + onDismiss() + } + } + + is AlbumItem -> { + navController.navigate("album/${item.id}") + onDismiss() + } + + is ArtistItem -> { + navController.navigate("artist/${item.id}") + onDismiss() + } + + is PlaylistItem -> { + navController.navigate("online_playlist/${item.id}") + onDismiss() + } + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is AlbumItem -> YouTubeAlbumMenu( + albumItem = item, + navController = navController, + onDismiss = menuState::dismiss, + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + onDismiss = menuState::dismiss, + ) + + is PlaylistItem -> YouTubePlaylistMenu( + playlist = item, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss, + ) + } + } + }, + ) + .animateItem() + ) + } + } + } +} +@Composable +fun SuggestionItem( + modifier: Modifier = Modifier, + query: String, + online: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit = {}, + onFillTextField: () -> Unit, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(RoundedCornerShape(18.dp)) + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(SuggestionItemHeight) + .padding(horizontal = 12.dp) + ) { + + Icon( + painter = painterResource( + if (online) R.drawable.search else R.drawable.history + ), + contentDescription = null, + modifier = Modifier + .padding(end = 12.dp) + .size(24.dp) + .alpha(0.7f), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = query, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (!online) { + IconButton( + onClick = onDelete, + modifier = Modifier.alpha(0.7f), + ) { + Icon( + painter = painterResource(R.drawable.close), + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + IconButton( + onClick = onFillTextField, + modifier = Modifier.alpha(0.7f), + ) { + Icon( + painter = painterResource(R.drawable.arrow_top_left), + contentDescription = "Fill", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AboutScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AboutScreen.kt new file mode 100644 index 00000000..373fff7a --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AboutScreen.kt @@ -0,0 +1,534 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.* +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.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.darkxvenom.airbeats.BuildConfig +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.ui.component.IconButton + +// ==================== SHIMMER EFFECT ==================== + +@Composable +fun shimmerEffect(): Brush { + val shimmerColors = listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + ) + + val transition = rememberInfiniteTransition(label = "shimmerEffect") + val translateAnim = transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), label = "shimmerEffect" + ) + + return Brush.linearGradient( + colors = shimmerColors, + start = Offset.Zero, + end = Offset(x = translateAnim.value, y = translateAnim.value) + ) +} + +// ==================== USER CARD ==================== + +@Composable +fun UserCard( + imageUrl: String, + name: String, + role: String, + onClick: () -> Unit +) { + var isPressed by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + .height(120.dp) + .scale(if (isPressed) 0.98f else 1f) + .shadow(8.dp, RoundedCornerShape(20.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + isPressed = true + onClick() + isPressed = false + }, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.8f), + ) + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 20.dp, end = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar - Left aligned + Box( + modifier = Modifier + .size(60.dp) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + MaterialTheme.colorScheme.primary.copy(alpha = 0.05f) + ) + ) + ) + .border(1.dp, MaterialTheme.colorScheme.primary, CircleShape) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Text - Next to avatar + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + Text( + text = role, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + + // Arrow - Right aligned + Icon( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + } +} + +// ==================== SOCIAL ICON ROW ==================== + +@Composable +fun SocialIconRow(uriHandler: UriHandler) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .shadow(4.dp, RoundedCornerShape(28.dp)), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.8f) + ) + ) { + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { uriHandler.openUri("https://www.facebook.com/venom.digital.creator") } + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.facebook), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { uriHandler.openUri("https://www.instagram.com/Dark__336/") } + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.instagram), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { uriHandler.openUri("https://github.com/d0x-dev") } + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.github), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { uriHandler.openUri("https://g.dev/Darkboy336") } + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.google), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton( + onClick = { uriHandler.openUri("https://AirBeats.stormx.pw/") } + ) { + Icon( + modifier = Modifier.size(22.dp), + painter = painterResource(R.drawable.resource_public), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +// ==================== MAIN ABOUT SCREEN ==================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val uriHandler = LocalUriHandler.current + val shimmerBrush = shimmerEffect() + + // Get player connection for album artwork + val playerConnection = LocalPlayerConnection.current + val mediaMetadata by playerConnection?.mediaMetadata?.collectAsState() + ?: remember { mutableStateOf(null) } + + Box(modifier = Modifier.fillMaxSize()) { + // 🎵 BLUR BACKGROUND + val artworkUrl = mediaMetadata?.thumbnailUrl + + artworkUrl?.let { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + + val isDarkTheme = + MaterialTheme.colorScheme.background.luminance() < 0.5f + + val overlayBrush = if (isDarkTheme) { + Brush.verticalGradient( + listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.85f) + ) + ) + } else { + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.25f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + MaterialTheme.colorScheme.background.copy(alpha = 0.85f) + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(overlayBrush) + ) + } + + // Main Scaffold with new TopAppBar + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = Color.Transparent, + topBar = { + // U-Shaped TopAppBar - NO BACK BUTTON + TopAppBar( + title = { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.about), + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + }, + navigationIcon = { + Spacer(modifier = Modifier.width(48.dp)) + }, + actions = { + Spacer(modifier = Modifier.width(48.dp)) + }, + modifier = Modifier + .clip( + RoundedCornerShape( + bottomStart = 30.dp, + bottomEnd = 30.dp + ) + ) + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.65f) + ) + ) + ) + .border( + width = 0.6.dp, + brush = Brush.horizontalGradient( + listOf( + Color.White.copy(alpha = 0.3f), + Color.White.copy(alpha = 0.1f), + Color.White.copy(alpha = 0.3f) + ) + ), + shape = RoundedCornerShape( + bottomStart = 30.dp, + bottomEnd = 30.dp + ) + ), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ), + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + // Content with proper padding from Scaffold + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + .padding(innerPadding) // This pushes content below the top bar + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), // Add small top padding for spacing + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Logo with shimmer + Box( + modifier = Modifier + .size(90.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation( + NavigationBarDefaults.Elevation + ) + ) + ) { + androidx.compose.foundation.Image( + painter = painterResource(R.drawable.airbeats_monochrome), + contentDescription = null, + colorFilter = ColorFilter.tint( + MaterialTheme.colorScheme.onBackground, + BlendMode.SrcIn + ), + modifier = Modifier + .matchParentSize() + .clickable { } + ) + + Box( + modifier = Modifier + .matchParentSize() + .background(shimmerBrush) + ) + } + + Spacer(Modifier.height(8.dp)) + + // App Name + Text( + text = buildAnnotatedString { + withStyle( + SpanStyle(color = MaterialTheme.colorScheme.primary) + ) { + append("Air") + } + withStyle( + SpanStyle(color = MaterialTheme.colorScheme.secondary) + ) { + append("Beats") + } + }, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + // Version badges + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 4.dp) + ) { + Text( + text = BuildConfig.VERSION_NAME.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.secondary, + shape = CircleShape + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) + + if (BuildConfig.DEBUG) { + Spacer(Modifier.width(4.dp)) + Text( + text = "DEBUG", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.secondary, + shape = CircleShape + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) + } + } + + Text( + text = "Dev By DarkXVenom 亗", + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = FontFamily.Monospace + ), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Social Icons + SocialIconRow(uriHandler) + + Spacer(Modifier.height(16.dp)) + + // Contributors Title - LEFT ALIGNED + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.group), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.contributors), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + // Contributors Cards - LEFT ALIGNED CONTENT + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start + ) { + UserCard( + imageUrl = "https://avatars.githubusercontent.com/u/218248866", + name = "Darkboy", + role = "Lead Developer", + onClick = { uriHandler.openUri("https://darkboy.pro") } + ) + + UserCard( + imageUrl = "https://avatars.githubusercontent.com/u/227567962", + name = "Venom", + role = "UI/UX Specialist", + onClick = { uriHandler.openUri("https://drkvenom786.github.io/webpage/") } + ) + } + + Spacer(Modifier.height(24.dp)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AccountSettings.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AccountSettings.kt new file mode 100644 index 00000000..e55a192e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AccountSettings.kt @@ -0,0 +1,298 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.utils.parseCookieString +import com.darkxvenom.airbeats.App.Companion.forgetAccount +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.* +import com.darkxvenom.airbeats.ui.component.* +import com.darkxvenom.airbeats.utils.rememberPreference +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val nameManager = remember { NamePreferenceManager(context) } + val currentDisplayName by nameManager.userName.collectAsState(initial = "") + + var showEditNameDialog by remember { mutableStateOf(false) } + + val (accountName, onAccountNameChange) = rememberPreference(AccountNameKey, "") + val (accountEmail, onAccountEmailChange) = rememberPreference(AccountEmailKey, "") + val (accountChannelHandle, onAccountChannelHandleChange) = + rememberPreference(AccountChannelHandleKey, "") + val (innerTubeCookie, onInnerTubeCookieChange) = + rememberPreference(InnerTubeCookieKey, "") + val (visitorData, onVisitorDataChange) = + rememberPreference(VisitorDataKey, "") + val (dataSyncId, onDataSyncIdChange) = + rememberPreference(DataSyncIdKey, "") + + val isLoggedIn = remember(innerTubeCookie) { + innerTubeCookie.isNotEmpty() && + "SAPISID" in parseCookieString(innerTubeCookie) + } + + val getAccountDisplayName = + remember(accountName, accountEmail, accountChannelHandle, isLoggedIn) { + when { + !isLoggedIn -> "" + accountName.isNotBlank() -> accountName + accountEmail.isNotBlank() -> accountEmail.substringBefore("@") + accountChannelHandle.isNotBlank() -> accountChannelHandle + else -> "No username" + } + } + + val getAccountDescription = + remember(accountEmail, accountChannelHandle, isLoggedIn) { + when { + !isLoggedIn -> null + accountEmail.isNotBlank() -> accountEmail + accountChannelHandle.isNotBlank() -> accountChannelHandle + else -> null + } + } + + val (useLoginForBrowse, onUseLoginForBrowseChange) = + rememberPreference(UseLoginForBrowse, true) + val (ytmSync, onYtmSyncChange) = + rememberPreference(YtmSyncKey, true) + + var showToken by remember { mutableStateOf(false) } + var showTokenEditor by remember { mutableStateOf(false) } + + // Use a simpler structure without SettingsPage first to isolate the issue + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.account)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + painter = painterResource(R.drawable.arrow_back), + contentDescription = stringResource(R.string.back) + ) + } + }, + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + // This innerPadding provides proper constraints + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + ) { + // Main content with horizontal padding + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + SettingsGeneralCategory( + title = stringResource(R.string.google), + items = listOf( + + // 🔹 EDIT DISPLAY NAME + { + PreferenceEntry( + title = { Text("Edit Display Name") }, + description = if (currentDisplayName.isNotBlank()) + "Current: $currentDisplayName" + else + "Not set", + icon = { Icon(painterResource(R.drawable.person), null) }, + onClick = { showEditNameDialog = true } + ) + }, + + // 🔹 LOGIN / LOGOUT + { + PreferenceEntry( + title = { + Text( + if (isLoggedIn) { + getAccountDisplayName.takeIf { it.isNotBlank() } + ?: stringResource(R.string.login) + } else { + stringResource(R.string.login) + } + ) + }, + description = if (isLoggedIn) getAccountDescription else null, + icon = { Icon(painterResource(R.drawable.login), null) }, + trailingContent = { + if (isLoggedIn) { + OutlinedButton(onClick = { + onInnerTubeCookieChange("") + onAccountNameChange("") + onAccountEmailChange("") + onAccountChannelHandleChange("") + onVisitorDataChange("") + onDataSyncIdChange("") + forgetAccount(context) + }) { + Text(stringResource(R.string.logout)) + } + } + }, + onClick = { + if (!isLoggedIn) + navController.navigate("login") + } + ) + }, + + // 🔹 ADVANCED LOGIN + { + PreferenceEntry( + title = { + Text( + if (!isLoggedIn) + stringResource(R.string.advanced_login) + else if (showToken) + stringResource(R.string.token_shown) + else + stringResource(R.string.token_hidden) + ) + }, + icon = { Icon(painterResource(R.drawable.token), null) }, + onClick = { + if (!isLoggedIn) { + showTokenEditor = true + } else { + if (!showToken) + showToken = true + else + showTokenEditor = true + } + } + ) + }, + + // 🔹 USE LOGIN FOR BROWSE + { + if (isLoggedIn) { + SwitchPreference( + title = { + Text(stringResource(R.string.use_login_for_browse)) + }, + description = stringResource(R.string.use_login_for_browse_desc), + icon = { + Icon(painterResource(R.drawable.person), null) + }, + checked = useLoginForBrowse, + onCheckedChange = { + YouTube.useLoginForBrowse = it + onUseLoginForBrowseChange(it) + } + ) + } + }, + + // 🔹 YTM SYNC + { + if (isLoggedIn) { + SwitchPreference( + title = { Text(stringResource(R.string.ytm_sync)) }, + icon = { + Icon(painterResource(R.drawable.cached), null) + }, + checked = ytmSync, + onCheckedChange = onYtmSyncChange + ) + } + }, + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 🔥 AVATAR SELECTOR + GlassCard( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + AvatarSelector(modifier = Modifier.padding(vertical = 8.dp)) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + + // 🔥 EDIT NAME DIALOG + if (showEditNameDialog) { + var newName by remember { + mutableStateOf(TextFieldValue(currentDisplayName)) + } + + AlertDialog( + onDismissRequest = { showEditNameDialog = false }, + title = { Text("Edit Display Name") }, + text = { + Column { + OutlinedTextField( + value = newName, + onValueChange = { + if (it.text.length <= 9) + newName = it + }, + label = { Text("Your name") }, + singleLine = true + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Changes will be applied after restarting the app", + style = MaterialTheme.typography.bodySmall + ) + } + }, + confirmButton = { + Button( + onClick = { + scope.launch { + nameManager.saveUserName(newName.text) + } + showEditNameDialog = false + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton( + onClick = { showEditNameDialog = false } + ) { + Text("Cancel") + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AppearanceSettings.kt new file mode 100644 index 00000000..0575383b --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/AppearanceSettings.kt @@ -0,0 +1,715 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.* +import com.darkxvenom.airbeats.ui.component.* +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import me.saket.squiggles.SquigglySlider +import timber.log.Timber + +// ==================== MAIN APPEARANCE SETTINGS SCREEN ==================== + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppearanceSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (dynamicTheme, onDynamicThemeChange) = rememberPreference( + DynamicThemeKey, + defaultValue = true + ) + val (playerTextAlignment, onPlayerTextAlignmentChange) = + rememberEnumPreference( + PlayerTextAlignmentKey, + defaultValue = PlayerTextAlignment.CENTER, + ) + + val (darkMode, onDarkModeChange) = rememberEnumPreference( + DarkModeKey, + defaultValue = DarkMode.AUTO + ) + + val (playerButtonsStyle, onPlayerButtonsStyleChange) = rememberEnumPreference( + PlayerButtonsStyleKey, + defaultValue = PlayerButtonsStyle.DEFAULT + ) + val (playerBackground, onPlayerBackgroundChange) = + rememberEnumPreference( + PlayerBackgroundStyleKey, + defaultValue = PlayerBackgroundStyle.DEFAULT, + ) + val (pureBlack, onPureBlackChange) = rememberPreference(PureBlackKey, defaultValue = false) + val (defaultOpenTab, onDefaultOpenTabChange) = rememberEnumPreference( + DefaultOpenTabKey, + defaultValue = NavigationTab.HOME + ) + val (lyricsPosition, onLyricsPositionChange) = rememberEnumPreference( + LyricsTextPositionKey, + defaultValue = LyricsPosition.CENTER + ) + val (lyricsClick, onLyricsClickChange) = rememberPreference(LyricsClickKey, defaultValue = true) + val (sliderStyle, onSliderStyleChange) = rememberEnumPreference( + SliderStyleKey, + defaultValue = SliderStyle.SQUIGGLY + ) + val (swipeThumbnail, onSwipeThumbnailChange) = rememberPreference( + SwipeThumbnailKey, + defaultValue = true + ) + val (gridItemSize, onGridItemSizeChange) = rememberEnumPreference( + GridItemsSizeKey, + defaultValue = GridItemSize.BIG + ) + val (animateLyrics, onAnimateLyricsChange) = rememberPreference( + AnimateLyricsKey, + defaultValue = true + ) + + val (rotateBackground, onRotateBackgroundChange) = rememberPreference( + key = RotateBackgroundKey, + defaultValue = false + ) + + // Estados de formas + val smallButtonsShapeState = rememberPreference( + key = SmallButtonsShapeKey, + defaultValue = DefaultSmallButtonsShape + ) + + val playPauseShapeState = rememberPreference( + key = PlayPauseButtonShapeKey, + defaultValue = DefaultPlayPauseButtonShape + ) + + val miniPlayerThumbnailShapeState = rememberPreference( + key = MiniPlayerThumbnailShapeKey, + defaultValue = DefaultMiniPlayerThumbnailShape + ) + + val (slimNav, onSlimNavChange) = rememberPreference(SlimNavBarKey, defaultValue = false) + + val isSystemInDarkTheme = isSystemInDarkTheme() + val useDarkTheme = + remember(darkMode, isSystemInDarkTheme) { + if (darkMode == DarkMode.AUTO) isSystemInDarkTheme else darkMode == DarkMode.ON + } + + // Automatically disable pureBlack when switching to light mode + LaunchedEffect(useDarkTheme) { + if (!useDarkTheme && pureBlack) { + onPureBlackChange(false) + } + } + + val (defaultChip, onDefaultChipChange) = rememberEnumPreference( + key = ChipSortTypeKey, + defaultValue = LibraryFilter.LIBRARY + ) + + var showSliderOptionDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSliderOptionDialog) { + DefaultDialog( + buttons = { + TextButton( + onClick = { showSliderOptionDialog = false } + ) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + onDismiss = { + showSliderOptionDialog = false + } + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .aspectRatio(1f) + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .border( + 1.dp, + if (sliderStyle == SliderStyle.DEFAULT) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(16.dp) + ) + .clickable { + onSliderStyleChange(SliderStyle.DEFAULT) + showSliderOptionDialog = false + } + .padding(16.dp) + ) { + var sliderValue by remember { + mutableFloatStateOf(0.5f) + } + Slider( + value = sliderValue, + valueRange = 0f..1f, + onValueChange = { + sliderValue = it + }, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(R.string.default_), + style = MaterialTheme.typography.labelLarge + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .aspectRatio(1f) + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .border( + 1.dp, + if (sliderStyle == SliderStyle.SQUIGGLY) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(16.dp) + ) + .clickable { + onSliderStyleChange(SliderStyle.SQUIGGLY) + showSliderOptionDialog = false + } + .padding(16.dp) + ) { + var sliderValue by remember { + mutableFloatStateOf(0.5f) + } + SquigglySlider( + value = sliderValue, + valueRange = 0f..1f, + onValueChange = { + sliderValue = it + }, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(R.string.squiggly), + style = MaterialTheme.typography.labelLarge + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .aspectRatio(1f) + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .border( + 1.dp, + if (sliderStyle == SliderStyle.SLIM) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(16.dp) + ) + .clickable { + onSliderStyleChange(SliderStyle.SLIM) + showSliderOptionDialog = false + } + .padding(16.dp) + ) { + var sliderValue by remember { + mutableFloatStateOf(0.5f) + } + Slider( + value = sliderValue, + valueRange = 0f..1f, + onValueChange = { + sliderValue = it + }, + thumb = { Spacer(modifier = Modifier.size(0.dp)) }, + track = { sliderState -> + PlayerSliderTrack( + sliderState = sliderState, + colors = SliderDefaults.colors() + ) + }, + modifier = Modifier + .weight(1f) + .pointerInput(Unit) { + detectTapGestures( + onPress = {} + ) + } + ) + + Text( + text = stringResource(R.string.slim), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + + // Get player connection for album artwork + val playerConnection = LocalPlayerConnection.current + val mediaMetadata by playerConnection?.mediaMetadata?.collectAsState() + ?: remember { mutableStateOf(null) } + + Box(modifier = Modifier.fillMaxSize()) { + // 🎵 BLUR BACKGROUND + val artworkUrl = mediaMetadata?.thumbnailUrl + + artworkUrl?.let { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + + val isDarkTheme = + MaterialTheme.colorScheme.background.luminance() < 0.5f + + val overlayBrush = if (isDarkTheme) { + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.85f) + ) + ) + } else { + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.25f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + MaterialTheme.colorScheme.background.copy(alpha = 0.85f) + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(overlayBrush) + ) + } + + // Main Scaffold with U-Shaped TopAppBar + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = Color.Transparent, + topBar = { + // U-Shaped TopAppBar + TopAppBar( + title = { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.appearance), + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + }, + navigationIcon = { + Spacer(modifier = Modifier.width(48.dp)) + }, + actions = { + Spacer(modifier = Modifier.width(48.dp)) + }, + modifier = Modifier + .clip( + RoundedCornerShape( + bottomStart = 30.dp, + bottomEnd = 30.dp + ) + ) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.65f) + ) + ) + ) + .border( + width = 0.6.dp, + brush = Brush.horizontalGradient( + colors = listOf( + Color.White.copy(alpha = 0.3f), + Color.White.copy(alpha = 0.1f), + Color.White.copy(alpha = 0.3f) + ) + ), + shape = RoundedCornerShape( + bottomStart = 30.dp, + bottomEnd = 30.dp + ) + ), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ), + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + // Content with proper scrolling + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + ) { + // Theme Category + SettingsGeneralCategory( + title = stringResource(R.string.theme), + items = listOf( + {SwitchPreference( + title = { Text(stringResource(R.string.enable_dynamic_theme)) }, + icon = { Icon(painterResource(R.drawable.palette), null) }, + checked = dynamicTheme, + onCheckedChange = onDynamicThemeChange, + )}, + {EnumListPreference( + title = { Text(stringResource(R.string.dark_theme)) }, + icon = { Icon(painterResource(R.drawable.dark_mode), null) }, + selectedValue = darkMode, + onValueSelected = onDarkModeChange, + valueText = { + when (it) { + DarkMode.ON -> stringResource(R.string.dark_theme_on) + DarkMode.OFF -> stringResource(R.string.dark_theme_off) + DarkMode.AUTO -> stringResource(R.string.dark_theme_follow_system) + } + }, + )}, + {AnimatedVisibility(useDarkTheme) { + SwitchPreference( + title = { Text(stringResource(R.string.pure_black)) }, + icon = { Icon(painterResource(R.drawable.contrast), null) }, + checked = pureBlack && useDarkTheme, + onCheckedChange = { newValue -> + if (useDarkTheme) { + onPureBlackChange(newValue) + } + }, + isEnabled = useDarkTheme + ) + }} + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Language preferences + SettingsGeneralCategory( + title = stringResource(R.string.app_language), + items = listOf( + { LanguagePreference() } + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Determine the options available based on the Android version + val availableBackgroundStyles = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + enumValues().toList() + } else { + enumValues().filter { + it != PlayerBackgroundStyle.BLUR + } + } + + // Also ensure that the selected value is compatible. + val safeSelectedValue = if (playerBackground == PlayerBackgroundStyle.BLUR && + Build.VERSION.SDK_INT < Build.VERSION_CODES.S + ) { + PlayerBackgroundStyle.DEFAULT + } else { + playerBackground + } + + // Player Category + SettingsGeneralCategory( + title = stringResource(R.string.player), + items = listOf( + {EnumListPreference( + title = { Text(stringResource(R.string.player_background_style)) }, + icon = { Icon(painterResource(R.drawable.gradient), null) }, + selectedValue = safeSelectedValue, + onValueSelected = onPlayerBackgroundChange, + valueText = { + when (it) { + PlayerBackgroundStyle.DEFAULT -> stringResource(R.string.follow_theme) + PlayerBackgroundStyle.GRADIENT -> stringResource(R.string.gradient) + PlayerBackgroundStyle.BLUR -> stringResource(R.string.player_background_blur) + } + }, + values = availableBackgroundStyles + )}, + + {ThumbnailCornerRadiusSelectorButton( + onRadiusSelected = { selectedRadius -> + Timber.tag("Thumbnail").d("Selected radio: $selectedRadius") + } + )}, + + { + UnifiedShapeSelectorButton( + smallButtonsShape = smallButtonsShapeState.value, + playPauseShape = playPauseShapeState.value, + miniPlayerShape = miniPlayerThumbnailShapeState.value, + onSmallButtonsShapeSelected = { newShape -> + smallButtonsShapeState.value = newShape + }, + onPlayPauseShapeSelected = { newShape -> + playPauseShapeState.value = newShape + }, + onMiniPlayerShapeSelected = { newShape -> + miniPlayerThumbnailShapeState.value = newShape + } + ) + }, + + {EnumListPreference( + title = { Text(stringResource(R.string.player_buttons_style)) }, + icon = { Icon(painterResource(R.drawable.palette), null) }, + selectedValue = playerButtonsStyle, + onValueSelected = onPlayerButtonsStyleChange, + valueText = { + when (it) { + PlayerButtonsStyle.DEFAULT -> stringResource(R.string.default_style) + PlayerButtonsStyle.PRIMARY -> stringResource(R.string.secondary_color_style) + PlayerButtonsStyle.TERTIARY -> stringResource(R.string.tertiary_color_style) + } + }, + )}, + + {PreferenceEntry( + title = { Text(stringResource(R.string.player_slider_style)) }, + description = + when (sliderStyle) { + SliderStyle.DEFAULT -> stringResource(R.string.default_) + SliderStyle.SQUIGGLY -> stringResource(R.string.squiggly) + SliderStyle.SLIM -> stringResource(R.string.slim) + }, + icon = { Icon(painterResource(R.drawable.sliders), null) }, + onClick = { + showSliderOptionDialog = true + }, + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.enable_swipe_thumbnail)) }, + icon = { Icon(painterResource(R.drawable.swipe), null) }, + checked = swipeThumbnail, + onCheckedChange = onSwipeThumbnailChange, + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.Rotatelyricsbackground)) }, + description = null, + icon = { Icon(painterResource(R.drawable.album), null) }, + checked = rotateBackground, + onCheckedChange = onRotateBackgroundChange + )}, + + {EnumListPreference( + title = { Text(stringResource(R.string.player_text_alignment)) }, + icon = { + Icon( + painter = + painterResource( + when (playerTextAlignment) { + PlayerTextAlignment.CENTER -> R.drawable.format_align_center + PlayerTextAlignment.SIDED -> R.drawable.format_align_left + }, + ), + contentDescription = null, + ) + }, + selectedValue = playerTextAlignment, + onValueSelected = onPlayerTextAlignmentChange, + valueText = { + when (it) { + PlayerTextAlignment.SIDED -> stringResource(R.string.sided) + PlayerTextAlignment.CENTER -> stringResource(R.string.center) + } + }, + )}, + + {EnumListPreference( + title = { Text(stringResource(R.string.lyrics_text_position)) }, + icon = { Icon(painterResource(R.drawable.lyrics), null) }, + selectedValue = lyricsPosition, + onValueSelected = onLyricsPositionChange, + valueText = { + when (it) { + LyricsPosition.LEFT -> stringResource(R.string.left) + LyricsPosition.CENTER -> stringResource(R.string.center) + LyricsPosition.RIGHT -> stringResource(R.string.right) + } + }, + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.lyrics_click_change)) }, + icon = { Icon(painterResource(R.drawable.lyrics), null) }, + checked = lyricsClick, + onCheckedChange = onLyricsClickChange, + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.animate_lyrics)) }, + icon = { Icon(painterResource(R.drawable.lyrics), null) }, + description = stringResource(R.string.animate_lyrics_desc), + checked = animateLyrics, + onCheckedChange = onAnimateLyricsChange + )} + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Misc Category + SettingsGeneralCategory( + title = stringResource(R.string.misc), + items = listOf( + {EnumListPreference( + title = { Text(stringResource(R.string.default_open_tab)) }, + icon = { Icon(painterResource(R.drawable.nav_bar), null) }, + selectedValue = defaultOpenTab, + onValueSelected = onDefaultOpenTabChange, + valueText = { + when (it) { + NavigationTab.HOME -> stringResource(R.string.home) + NavigationTab.EXPLORE -> stringResource(R.string.explore) + NavigationTab.LIBRARY -> stringResource(R.string.filter_library) + } + }, + )}, + + {ListPreference( + title = { Text(stringResource(R.string.default_lib_chips)) }, + icon = { Icon(painterResource(R.drawable.tab), null) }, + selectedValue = defaultChip, + values = listOf( + LibraryFilter.LIBRARY, LibraryFilter.PLAYLISTS, LibraryFilter.SONGS, + LibraryFilter.ALBUMS, LibraryFilter.ARTISTS + ), + valueText = { + when (it) { + LibraryFilter.SONGS -> stringResource(R.string.songs) + LibraryFilter.ARTISTS -> stringResource(R.string.artists) + LibraryFilter.ALBUMS -> stringResource(R.string.albums) + LibraryFilter.PLAYLISTS -> stringResource(R.string.playlists) + LibraryFilter.LIBRARY -> stringResource(R.string.filter_library) + LibraryFilter.LOCAL -> stringResource(R.string.filter_local) + } + }, + onValueSelected = onDefaultChipChange, + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.slim_navbar)) }, + icon = { Icon(painterResource(R.drawable.nav_bar), null) }, + checked = slimNav, + onCheckedChange = onSlimNavChange + )}, + + {EnumListPreference( + title = { Text(stringResource(R.string.grid_cell_size)) }, + icon = { Icon(painterResource(R.drawable.grid_view), null) }, + selectedValue = gridItemSize, + onValueSelected = onGridItemSizeChange, + valueText = { + when (it) { + GridItemSize.SMALL -> stringResource(R.string.small) + GridItemSize.BIG -> stringResource(R.string.big) + } + }, + )}, + ) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Avatar section completely removed + + Spacer(modifier = Modifier.height(24.dp)) + } + } + } +} + +enum class DarkMode { + ON, + OFF, + AUTO, +} + +enum class NavigationTab { + HOME, + EXPLORE, + LIBRARY, +} + +enum class LyricsPosition { + LEFT, + CENTER, + RIGHT, +} + +enum class PlayerTextAlignment { + SIDED, + CENTER, +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/BackupAndRestore.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/BackupAndRestore.kt new file mode 100644 index 00000000..d5ca69ca --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/BackupAndRestore.kt @@ -0,0 +1,824 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.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.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +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.clip +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.annotation.ExperimentalCoilApi +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.tryOrNull +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.PreferenceEntry +import com.darkxvenom.airbeats.ui.component.SettingsGeneralCategory +import com.darkxvenom.airbeats.ui.component.SettingsPage +import com.darkxvenom.airbeats.ui.component.SwitchPreference +import com.darkxvenom.airbeats.ui.menu.OnlinePlaylistAdder +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.ui.utils.formatFileSize +import com.darkxvenom.airbeats.viewmodels.BackupRestoreViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalCoilApi::class, ExperimentalMaterial3Api::class) +@SuppressLint("LogNotTimber") +@Composable +fun BackupAndRestore( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: BackupRestoreViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val playerCache = LocalPlayerConnection.current?.service?.playerCache + + // Statuses + var uploadStatus by remember { mutableStateOf(null) } + var showVisitorDataDialog by remember { mutableStateOf(false) } + var showVisitorDataResetDialog by remember { mutableStateOf(false) } + var importedTitle by remember { mutableStateOf("") } + val importedSongs = remember { mutableStateListOf() } + var showChoosePlaylistDialogOnline by remember { mutableStateOf(false) } + var isProgressStarted by remember { mutableStateOf(false) } + var progressPercentage by remember { mutableIntStateOf(0) } + + // NEW: Status to control automatic upload to the cloud + var enableCloudUpload by remember { + mutableStateOf( + context.getSharedPreferences("backup_settings", Context.MODE_PRIVATE) + .getBoolean("enable_cloud_upload", false) + ) + } + + // Cache stats + var playerCacheSize by remember { mutableLongStateOf(tryOrNull { playerCache?.cacheSpace } ?: 0L) } + var isClearing by remember { mutableStateOf(false) } + + val animatedPlayerCacheSize by animateFloatAsState( + targetValue = if (playerCacheSize > 0) 1f else 0f, + label = "playerCacheProgress" + ) + + // Update cache size + LaunchedEffect(playerCache) { + while (true) { + delay(1000) + playerCacheSize = tryOrNull { playerCache?.cacheSpace } ?: 0L + } + } + + // Launchers + val backupLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> + if (uri != null) { + viewModel.backup(context, uri) + + // MODIFIED: Only upload to the cloud if the user has enabled it. + if (enableCloudUpload) { + coroutineScope.launch { + uploadStatus = UploadStatus.Uploading + val fileUrl = uploadBackupToFilebin(context, uri) + uploadStatus = if (fileUrl != null) { + UploadStatus.Success(fileUrl) + } else { + UploadStatus.Failure + } + } + } + } + } + + val restoreLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + viewModel.restore(context, uri) + } + } + + val importPlaylistFromCsv = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val result = viewModel.importPlaylistFromCsv(context, uri) + importedSongs.clear() + importedSongs.addAll(result) + if (importedSongs.isNotEmpty()) { + showChoosePlaylistDialogOnline = true + } + } + + val importM3uLauncherOnline = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val result = viewModel.loadM3UOnline(context, uri) + importedSongs.clear() + importedSongs.addAll(result) + if (importedSongs.isNotEmpty()) { + showChoosePlaylistDialogOnline = true + } + } + + SettingsPage( + title = stringResource(R.string.backup_restore), + navController = navController, + scrollBehavior = scrollBehavior, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SettingsGeneralCategory( + title = stringResource(R.string.backup_restore), + items = listOf( + {SwitchPreference( + title = { Text(stringResource(R.string.cloud_upload_title)) }, + icon = { Icon(painterResource(R.drawable.cloud_lock), null) }, + checked = enableCloudUpload, + description = stringResource( + if (enableCloudUpload) { + R.string.cloud_upload_enabled_description + } else { + R.string.cloud_upload_disabled_description + } + ), + onCheckedChange = { isEnabled -> + enableCloudUpload = isEnabled + // Save preference + context.getSharedPreferences("backup_settings", Context.MODE_PRIVATE) + .edit() + .putBoolean("enable_cloud_upload", isEnabled) + .apply() + } + )}, + {PreferenceEntry( + title = { Text(stringResource(R.string.backup)) }, + icon = { Icon(painterResource(R.drawable.backup), null) }, + description = stringResource(if (enableCloudUpload) R.string.backup_with_cloud else R.string.backup_description), + isEnabled = uploadStatus !is UploadStatus.Uploading, + onClick = { + val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") + backupLauncher.launch( + "${context.getString(R.string.app_name)}_${ + LocalDateTime.now().format(formatter) + }.backup" + ) + } + )}, + {PreferenceEntry( + title = { Text(stringResource(R.string.restore)) }, + icon = { Icon(painterResource(R.drawable.restore), null) }, + description = stringResource(R.string.restore_description), + isEnabled = uploadStatus !is UploadStatus.Uploading, + onClick = { + restoreLauncher.launch(arrayOf("application/octet-stream")) + } + )}, + {AnimatedVisibility( + visible = uploadStatus != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) {MinimalUploadStatus(uploadStatus) { + copyToClipboard(context, (uploadStatus as UploadStatus.Success).fileUrl) + }}} + ) + ) + + // VISITOR_DATA Card + MinimalVisitorDataCard( + playerCacheSize = playerCacheSize, + progress = animatedPlayerCacheSize, + isClearing = isClearing, + onResetClick = { showVisitorDataResetDialog = true }, + onInfoClick = { showVisitorDataDialog = true } + ) + } + + // Dialogs + if (showVisitorDataDialog) { + MinimalInfoDialog( + icon = painterResource(R.drawable.info), + title = stringResource(R.string.visitor_data_info_title), + message = stringResource(R.string.visitor_data_info_intro) + "\n\n" + + stringResource(R.string.visitor_data_info_problems) + "\n\n" + + stringResource(R.string.visitor_data_info_solution), + onDismiss = { showVisitorDataDialog = false } + ) + } + + if (showVisitorDataResetDialog) { + MinimalConfirmDialog( + icon = painterResource(R.drawable.replay), + title = stringResource(R.string.visitor_data_reset_title), + message = stringResource(R.string.visitor_data_reset_message), + confirmText = stringResource(R.string.visitor_data_reset_confirm), + onConfirm = { + isClearing = true + coroutineScope.launch(Dispatchers.IO) { + try { + // Clear song cache + playerCache?.keys?.toList()?.forEach { key -> + tryOrNull { playerCache.removeResource(key) } + } + + // Reset VISITOR_DATA + viewModel.resetVisitorData(context) + + delay(500) // Short delay to ensure completion + + withContext(Dispatchers.Main) { + playerCacheSize = 0L + isClearing = false + showVisitorDataResetDialog = false + } + } catch (e: Exception) { + Log.e("BackupRestore", "Error when resetting VISITOR_DATA", e) + withContext(Dispatchers.Main) { + isClearing = false + showVisitorDataResetDialog = false + } + } + } + }, + onDismiss = { showVisitorDataResetDialog = false } + ) + } + + OnlinePlaylistAdder( + isVisible = showChoosePlaylistDialogOnline, + allowSyncing = false, + initialTextFieldValue = importedTitle, + songs = importedSongs, + onDismiss = { showChoosePlaylistDialogOnline = false }, + onProgressStart = { newVal -> isProgressStarted = newVal }, + onPercentageChange = { newPercentage -> progressPercentage = newPercentage } + ) + + LaunchedEffect(progressPercentage, isProgressStarted) { + if (isProgressStarted && progressPercentage == 99) { + delay(10000) + if (progressPercentage == 99) { + isProgressStarted = false + progressPercentage = 0 + } + } + } + + if (isProgressStarted) { + MinimalLoadingOverlay(progress = progressPercentage) + } +} + + +@Composable +private fun MinimalVisitorDataCard( + playerCacheSize: Long, + progress: Float, + isClearing: Boolean, + onResetClick: () -> Unit, + onInfoClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.replay), + contentDescription = null, +// tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(20.dp) + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.visitor_data_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + } + } + + Text( + text = stringResource(R.string.visitor_data_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Cache indicator + if (playerCacheSize > 0 || isClearing) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + LinearProgressIndicator( + progress = { if (isClearing) 0f else progress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = if (isClearing) { + stringResource(R.string.cache_clearing) + } else { + stringResource(R.string.song_cache) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = formatFileSize(playerCacheSize), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onInfoClick, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + enabled = !isClearing + ) { + Icon( + painter = painterResource(R.drawable.help), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.info_button), style = MaterialTheme.typography.labelLarge) + } + + FilledTonalButton( + onClick = onResetClick, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + enabled = !isClearing + ) { + if (isClearing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + painter = painterResource(R.drawable.replay), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.reset_button), style = MaterialTheme.typography.labelLarge) + } + } + } + } +} + +@Composable +private fun MinimalUploadStatus( + uploadStatus: UploadStatus?, + onCopyClick: () -> Unit +) { + when (uploadStatus) { + is UploadStatus.Uploading -> { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + Text( + text = stringResource(R.string.uploading_backup), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + is UploadStatus.Success -> { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.check_circle), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.backup_success), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium + ) + } + + Text( + text = uploadStatus.fileUrl, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + + Button( + onClick = onCopyClick, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Icon( + painter = painterResource(R.drawable.content_copy), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.copy_link)) + } + } + } + } + + is UploadStatus.Failure -> { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.errorContainer + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(R.drawable.error), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.backup_upload_error), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + + null -> {} + } +} + +@Composable +private fun MinimalLoadingOverlay(progress: Int) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.size(64.dp), + strokeWidth = 4.dp + ) + Text( + text = "$progress%", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = stringResource(R.string.processing_songs), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun MinimalInfoDialog( + icon: Painter, + title: String, + message: String, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.understood)) + } + }, + shape = RoundedCornerShape(24.dp) + ) +} + +@Composable +private fun MinimalConfirmDialog( + icon: Painter, + title: String, + message: String, + confirmText: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge + ) + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium + ) + }, + confirmButton = { + Button( + onClick = onConfirm, + shape = RoundedCornerShape(12.dp) + ) { + Text(confirmText) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + }, + shape = RoundedCornerShape(24.dp) + ) +} + +// Filebin upload function (no significant changes) +@SuppressLint("LogNotTimber") +suspend fun uploadBackupToFilebin( + context: Context, + uri: Uri, + progressCallback: (Float) -> Unit = {} +): String? { + return withContext(Dispatchers.IO) { + val tempFile = File(context.cacheDir, "temp_backup_${System.currentTimeMillis()}.backup") + + try { + val inputStream = context.contentResolver.openInputStream(uri) + ?: return@withContext null + + inputStream.use { input -> + val fileSize = try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex != -1 && cursor.moveToFirst()) { + cursor.getLong(sizeIndex) + } else { + input.available().toLong() + } + } ?: input.available().toLong() + } catch (e: Exception) { + Log.w("BackupRestore", "The file size could not be obtained: ${e.message}") + input.available().toLong() + } + + var totalBytesRead = 0L + tempFile.outputStream().use { outputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + + while (input.read(buffer).also { bytesRead = it } != -1) { + outputStream.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + if (fileSize > 0) { + progressCallback(totalBytesRead / fileSize.toFloat() * 0.5f) + } + } + } + } + + val binId = UUID.randomUUID().toString().substring(0, 8) + + val fileRequestBody = object : RequestBody() { + override fun contentType() = "application/octet-stream".toMediaTypeOrNull() + override fun contentLength() = tempFile.length() + + override fun writeTo(sink: BufferedSink) { + tempFile.inputStream().use { input -> + val fileSize = tempFile.length().toFloat() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalBytesRead = 0L + + while (input.read(buffer).also { bytesRead = it } != -1) { + sink.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead + val uploadProgress = 0.5f + (totalBytesRead / fileSize * 0.5f) + progressCallback(uploadProgress) + } + } + } + } + + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + val fileName = tempFile.name + val request = Request.Builder() + .url("https://filebin.net/$binId/$fileName") + .put(fileRequestBody) + .build() + + val response = client.newCall(request).execute() + + if (!response.isSuccessful) { + Log.e("BackupRestore", "Error in server response: ${response.code}") + return@withContext null + } + + return@withContext "https://filebin.net/$binId/$fileName" + + } catch (e: Exception) { + Log.e("BackupRestore", "Error during upload", e) + return@withContext null + } finally { + if (tempFile.exists()) { + tryOrNull { tempFile.delete() } + } + } + } +} + +@SuppressLint("LogNotTimber") +fun copyToClipboard(context: Context, text: String) { + try { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Backup URL", text) + clipboard.setPrimaryClip(clip) + } catch (e: Exception) { + Log.e("BackupRestore", "Error copying to clipboard: ${e.message}") + } +} + +sealed class UploadStatus { + data object Uploading : UploadStatus() + data class Success(val fileUrl: String) : UploadStatus() + data object Failure : UploadStatus() +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/ContentSettings.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/ContentSettings.kt new file mode 100644 index 00000000..7c06bba6 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/ContentSettings.kt @@ -0,0 +1,221 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.darkxvenom.airbeats.NotificationPermissionPreference +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.ContentCountryKey +import com.darkxvenom.airbeats.constants.ContentLanguageKey +import com.darkxvenom.airbeats.constants.CountryCodeToName +import com.darkxvenom.airbeats.constants.EnableKugouKey +import com.darkxvenom.airbeats.constants.EnableLrcLibKey +import com.darkxvenom.airbeats.constants.HideExplicitKey +import com.darkxvenom.airbeats.constants.HistoryDuration +import com.darkxvenom.airbeats.constants.LanguageCodeToName +import com.darkxvenom.airbeats.constants.PreferredLyricsProvider +import com.darkxvenom.airbeats.constants.PreferredLyricsProviderKey +import com.darkxvenom.airbeats.constants.ProxyEnabledKey +import com.darkxvenom.airbeats.constants.ProxyTypeKey +import com.darkxvenom.airbeats.constants.ProxyUrlKey +import com.darkxvenom.airbeats.constants.QuickPicks +import com.darkxvenom.airbeats.constants.QuickPicksKey +import com.darkxvenom.airbeats.constants.SYSTEM_DEFAULT +import com.darkxvenom.airbeats.constants.TopSize +import com.darkxvenom.airbeats.ui.component.EditTextPreference +import com.darkxvenom.airbeats.ui.component.ListPreference +import com.darkxvenom.airbeats.ui.component.SettingsGeneralCategory +import com.darkxvenom.airbeats.ui.component.SettingsPage +import com.darkxvenom.airbeats.ui.component.SliderPreference +import com.darkxvenom.airbeats.ui.component.SwitchPreference +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import java.net.Proxy + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContentSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (contentLanguage, onContentLanguageChange) = rememberPreference( + key = ContentLanguageKey, + defaultValue = "system" + ) + val (contentCountry, onContentCountryChange) = rememberPreference( + key = ContentCountryKey, + defaultValue = "system" + ) + val (hideExplicit, onHideExplicitChange) = rememberPreference( + key = HideExplicitKey, + defaultValue = false + ) + val (proxyEnabled, onProxyEnabledChange) = rememberPreference( + key = ProxyEnabledKey, + defaultValue = false + ) + val (proxyType, onProxyTypeChange) = rememberEnumPreference( + key = ProxyTypeKey, + defaultValue = Proxy.Type.HTTP + ) + val (proxyUrl, onProxyUrlChange) = rememberPreference( + key = ProxyUrlKey, + defaultValue = "host:port" + ) + val (lengthTop, onLengthTopChange) = rememberPreference( + key = TopSize, + defaultValue = "50" + ) + val (historyDuration, onHistoryDurationChange) = rememberPreference( + key = HistoryDuration, + defaultValue = 30f + ) + val (quickPicks, onQuickPicksChange) = rememberEnumPreference( + key = QuickPicksKey, + defaultValue = QuickPicks.QUICK_PICKS + ) + val (enableKugou, onEnableKugouChange) = rememberPreference( + key = EnableKugouKey, + defaultValue = true + ) + val (enableLrclib, onEnableLrclibChange) = rememberPreference( + key = EnableLrcLibKey, + defaultValue = true + ) + val (preferredProvider, onPreferredProviderChange) = rememberEnumPreference( + key = PreferredLyricsProviderKey, + defaultValue = PreferredLyricsProvider.LRCLIB + ) + + + SettingsPage( + title = stringResource(R.string.content), + navController = navController, + scrollBehavior = scrollBehavior + ) { + // General settings + SettingsGeneralCategory( + title = stringResource(R.string.general), + items = listOf( + {ListPreference( + title = { Text(stringResource(R.string.content_language)) }, + icon = { Icon(painterResource(R.drawable.language), null) }, + selectedValue = contentLanguage, + values = listOf(SYSTEM_DEFAULT) + LanguageCodeToName.keys.toList(), + valueText = { + LanguageCodeToName.getOrElse(it) { stringResource(R.string.system_default) } + }, + onValueSelected = onContentLanguageChange, + )}, + {ListPreference( + title = { Text(stringResource(R.string.content_country)) }, + icon = { Icon(painterResource(R.drawable.location_on), null) }, + selectedValue = contentCountry, + values = listOf(SYSTEM_DEFAULT) + CountryCodeToName.keys.toList(), + valueText = { + CountryCodeToName.getOrElse(it) { stringResource(R.string.system_default) } + }, + onValueSelected = onContentCountryChange, + )}, + + // Hide explicit content + {SwitchPreference( + title = { Text(stringResource(R.string.hide_explicit)) }, + icon = { Icon(painterResource(R.drawable.explicit), null) }, + checked = hideExplicit, + onCheckedChange = onHideExplicitChange, + )}, + + {NotificationPermissionPreference()}, + ) + ) + + // Proxy settings + SettingsGeneralCategory( + title = stringResource(R.string.proxy), + items = listOf( + {SwitchPreference( + title = { Text(stringResource(R.string.enable_proxy)) }, + icon = { Icon(painterResource(R.drawable.wifi_proxy), null) }, + checked = proxyEnabled, + onCheckedChange = onProxyEnabledChange, + )}, + {if (proxyEnabled) { + Column { + ListPreference( + title = { Text(stringResource(R.string.proxy_type)) }, + selectedValue = proxyType, + values = listOf(Proxy.Type.HTTP, Proxy.Type.SOCKS), + valueText = { it.name }, + onValueSelected = onProxyTypeChange, + ) + EditTextPreference( + title = { Text(stringResource(R.string.proxy_url)) }, + value = proxyUrl, + onValueChange = onProxyUrlChange, + ) + } + }} + ) + ) + + // Lyrics settings + SettingsGeneralCategory( + title = stringResource(R.string.lyrics), + items = listOf( + {SwitchPreference( + title = { Text(stringResource(R.string.enable_lrclib)) }, + icon = { Icon(painterResource(R.drawable.lyrics), null) }, + checked = enableLrclib, + onCheckedChange = onEnableLrclibChange, + )}, + {SwitchPreference( + title = { Text(stringResource(R.string.enable_kugou)) }, + icon = { Icon(painterResource(R.drawable.lyrics), null) }, + checked = enableKugou, + onCheckedChange = onEnableKugouChange, + )}, + ) + ) + + // Misc settings + SettingsGeneralCategory( + title = stringResource(R.string.misc), + items = listOf( + {EditTextPreference( + title = { Text(stringResource(R.string.top_length)) }, + icon = { Icon(painterResource(R.drawable.trending_up), null) }, + value = lengthTop, + isInputValid = { it.toIntOrNull()?.let { num -> num > 0 } == true }, + onValueChange = onLengthTopChange, + )}, + {ListPreference( + title = { Text(stringResource(R.string.set_quick_picks)) }, + icon = { Icon(painterResource(R.drawable.home_outlined), null) }, + selectedValue = quickPicks, + values = listOf(QuickPicks.QUICK_PICKS, QuickPicks.LAST_LISTEN), + valueText = { + when (it) { + QuickPicks.QUICK_PICKS -> stringResource(R.string.quick_picks) + QuickPicks.LAST_LISTEN -> stringResource(R.string.last_song_listened) + } + }, + onValueSelected = onQuickPicksChange, + )}, + {SliderPreference( + title = { Text(stringResource(R.string.history_duration)) }, + icon = { Icon(painterResource(R.drawable.history), null) }, + value = historyDuration, + onValueChange = onHistoryDurationChange, + )}, + ) + ) + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/DiscordLoginScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/DiscordLoginScreen.kt new file mode 100644 index 00000000..75224f76 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/DiscordLoginScreen.kt @@ -0,0 +1,171 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +import android.annotation.SuppressLint +import android.util.Log +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.JavascriptInterface +import android.webkit.JsResult +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebStorage +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.DiscordTokenKey +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@SuppressLint("SetJavaScriptEnabled") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscordLoginScreen(navController: NavController) { + val scope = rememberCoroutineScope() + var discordToken by rememberPreference(DiscordTokenKey, "") + var webView: WebView? = null + + AndroidView( + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .fillMaxSize(), + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + WebView.setWebContentsDebuggingEnabled(true) + + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.setSupportZoom(true) + settings.builtInZoomControls = true + + CookieManager.getInstance().apply { + removeAllCookies(null) + flush() + } + + WebStorage.getInstance().deleteAllData() + + addJavascriptInterface(object { + @JavascriptInterface + fun onRetrieveToken(token: String) { + Log.d("DiscordWebView", "Token: $token") + if (token != "null" && token != "error") { + discordToken = token + scope.launch(Dispatchers.Main) { + webView?.loadUrl("about:blank") + navController.navigateUp() + } + } + } + }, "Android") + + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String) { + if (url.contains("/channels/@me") || url.contains("/app")) { + view.evaluateJavascript( + """ + (function() { + try { + var token = localStorage.getItem("token"); + if (token) { + Android.onRetrieveToken(token.slice(1, -1)); + } else { + // fallback إلى alert (منطق kizzy) + var i = document.createElement('iframe'); + document.body.appendChild(i); + setTimeout(function() { + try { + var alt = i.contentWindow.localStorage.token; + if (alt) { + alert(alt.slice(1, -1)); + } else { + alert("null"); + } + } catch (e) { + alert("error"); + } + }, 1000); + } + } catch (e) { + alert("error"); + } + })(); + """.trimIndent(), null + ) + } + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean = false + } + + webChromeClient = object : WebChromeClient() { + override fun onJsAlert( + view: WebView, + url: String, + message: String, + result: JsResult + ): Boolean { + if (message != "null" && message != "error") { + discordToken = message + scope.launch(Dispatchers.Main) { + view.loadUrl("about:blank") + navController.navigateUp() + } + } + result.confirm() + return true + } + } + + webView = this + loadUrl("https://discord.com/login") + } + } + ) + + TopAppBar( + title = { Text(stringResource(R.string.action_login)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + ) + + BackHandler(enabled = webView?.canGoBack() == true) { + webView?.goBack() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/DiscordSettings.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/DiscordSettings.kt new file mode 100644 index 00000000..793f06aa --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/DiscordSettings.kt @@ -0,0 +1,830 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +import android.content.Intent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.WindowInsetsSides +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.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.common.Player.STATE_READY +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.DiscordInfoDismissedKey +import com.darkxvenom.airbeats.constants.DiscordNameKey +import com.darkxvenom.airbeats.constants.DiscordTokenKey +import com.darkxvenom.airbeats.constants.DiscordUseDetailsKey +import com.darkxvenom.airbeats.constants.DiscordUsernameKey +import com.darkxvenom.airbeats.constants.EnableDiscordRPCKey +import com.darkxvenom.airbeats.constants.SliderStyle +import com.darkxvenom.airbeats.constants.SliderStyleKey +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.InfoLabel +import com.darkxvenom.airbeats.ui.component.PreferenceEntry +import com.darkxvenom.airbeats.ui.component.PreferenceGroupTitle +import com.darkxvenom.airbeats.ui.component.SwitchPreference +import com.darkxvenom.airbeats.ui.component.TextFieldDialog +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.makeTimeString +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference +import com.my.kizzy.rpc.KizzyRPC +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import me.saket.squiggles.SquigglySlider + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscordSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val playerConnection = LocalPlayerConnection.current ?: return + val song by playerConnection.currentSong.collectAsState(null) + val isPlaying by playerConnection.isPlaying.collectAsState() + + val playbackState by playerConnection.playbackState.collectAsState() + var position by rememberSaveable(playbackState) { + mutableLongStateOf(playerConnection.player.currentPosition) + } + var duration by rememberSaveable(playbackState) { + mutableLongStateOf(playerConnection.player.duration) + } + + val coroutineScope = rememberCoroutineScope() + + var discordToken by rememberPreference(DiscordTokenKey, "") + var discordUsername by rememberPreference(DiscordUsernameKey, "") + var discordName by rememberPreference(DiscordNameKey, "") + var infoDismissed by rememberPreference(DiscordInfoDismissedKey, false) + + val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.DEFAULT) + + LaunchedEffect(discordToken) { + val token = discordToken + if (token.isEmpty()) return@LaunchedEffect + coroutineScope.launch(Dispatchers.IO) { + KizzyRPC.getUserInfo(token).onSuccess { + discordUsername = it.username + discordName = it.name + } + } + } + + LaunchedEffect(playbackState) { + if (playbackState == STATE_READY) { + while (isActive) { + delay(100) + position = playerConnection.player.currentPosition + duration = playerConnection.player.duration + } + } + } + + val (discordRPC, onDiscordRPCChange) = rememberPreference( + key = EnableDiscordRPCKey, + defaultValue = true + ) + + val (useDetails, onUseDetailsChange) = rememberPreference( + key = DiscordUseDetailsKey, + defaultValue = false + ) + + val isLoggedIn = remember(discordToken) { discordToken != "" } + var showTokenDialog by rememberSaveable { mutableStateOf(false) } + + if (showTokenDialog) { + TextFieldDialog( + onDismiss = { showTokenDialog = false }, + icon = { Icon(painterResource(R.drawable.token), null) }, + onDone = { + discordToken = it + showTokenDialog = false + }, + singleLine = true, + isInputValid = { it.isNotEmpty() }, + extraContent = { + InfoLabel(text = stringResource(R.string.token_adv_login_description)) + } + ) + } + + Column( + Modifier + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + .verticalScroll(rememberScrollState()) + ) { + Spacer( + Modifier.windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top) + ) + ) + + // Banner informativo mejorado + AnimatedVisibility( + visible = !infoDismissed, + enter = fadeIn(), + exit = fadeOut() + ) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(end = 16.dp) + ) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.discord_integration), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.discord_information), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + TextButton( + onClick = { infoDismissed = true }, + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(stringResource(R.string.dismiss)) + } + } + } + + // Sección de cuenta mejorada + PreferenceGroupTitle(title = stringResource(R.string.account)) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = CircleShape, + color = if (isLoggedIn) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(R.drawable.discord), + contentDescription = null, + tint = if (isLoggedIn) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.padding(12.dp) + ) + } + + Spacer(Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (isLoggedIn) discordName else stringResource(R.string.not_logged_in), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.alpha(if (isLoggedIn) 1f else 0.7f) + ) + if (discordUsername.isNotEmpty()) { + Text( + text = "@$discordUsername", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (isLoggedIn) { + OutlinedButton( + onClick = { + discordName = "" + discordToken = "" + discordUsername = "" + } + ) { + Text(stringResource(R.string.logout)) + } + } else { + FilledTonalButton( + onClick = { navController.navigate("settings/discord/login") } + ) { + Text(stringResource(R.string.action_login)) + } + } + } + } + + if (!isLoggedIn) { + PreferenceEntry( + title = { Text(stringResource(R.string.advanced_login)) }, + icon = { Icon(painterResource(R.drawable.token), null) }, + onClick = { showTokenDialog = true } + ) + } + + // Opciones + PreferenceGroupTitle(title = stringResource(R.string.options)) + + SwitchPreference( + title = { Text(stringResource(R.string.enable_discord_rpc)) }, + checked = discordRPC, + onCheckedChange = onDiscordRPCChange, + isEnabled = isLoggedIn, + ) + + SwitchPreference( + title = { Text(stringResource(R.string.discord_use_details)) }, + description = stringResource(R.string.discord_use_details_description), + checked = useDetails, + onCheckedChange = onUseDetailsChange, + isEnabled = isLoggedIn && discordRPC, + ) + + // Preview mejorado + PreferenceGroupTitle(title = stringResource(R.string.preview)) + + EnhancedRichPresence( + song = song, + position = position, + duration = duration, + isPlaying = isPlaying, + sliderStyle = sliderStyle + ) + + Spacer(Modifier.height(16.dp)) + } + + TopAppBar( + title = { Text(stringResource(R.string.discord_integration)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + scrollBehavior = scrollBehavior + ) +} + +@Composable +fun EnhancedRichPresence( + song: Song?, + position: Long, + duration: Long, + isPlaying: Boolean, + sliderStyle: SliderStyle +) { + val context = LocalContext.current + + // Animación para el gradiente + val gradientAlpha by animateFloatAsState( + targetValue = if (song != null) 0.15f else 0f, + animationSpec = tween(durationMillis = 600, easing = FastOutSlowInEasing), + label = "gradientAlpha" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Box { + // Fondo con gradiente sutil + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.primaryContainer.copy(alpha = gradientAlpha), + MaterialTheme.colorScheme.surface + ) + ) + ) + ) + + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Header - Rediseñado + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Logo y título mejorados + Row(verticalAlignment = Alignment.CenterVertically) { + // Logo con contenedor circular y gradiente + Box( + modifier = Modifier + .size(48.dp) + .shadow(4.dp, CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primaryContainer + ) + ), + CircleShape + ) + .border( + 2.dp, + MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.discord), + contentDescription = "Discord", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(Modifier.width(12.dp)) + + Column { + Text( + text = "AirBeats", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Listening on Discord", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Indicador de reproducción mejorado + if (song != null && isPlaying) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer, + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + ), + shadowElevation = 2.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Icono de play animado + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "LIVE", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp + ) + } + } + } + } + + Spacer(Modifier.height(20.dp)) + + // Contenido principal + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth() + ) { + // Thumbnail con diseño mejorado + Box( + modifier = Modifier + .size(120.dp) + .shadow(8.dp, RoundedCornerShape(16.dp)) + ) { + Card( + modifier = Modifier.fillMaxSize(), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Box(Modifier.fillMaxSize()) { + AsyncImage( + model = song?.song?.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .run { + if (song == null) { + background(MaterialTheme.colorScheme.surfaceVariant) + } else this + }, + contentScale = ContentScale.Crop + ) + + // Overlay sutil + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.1f) + ) + ) + ) + ) + + // Avatar del artista mejorado + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + ) { + // Logo de AirBeats como fallback mejorado + val artistAvatar = song?.artists?.firstOrNull()?.thumbnailUrl + + Card( + modifier = Modifier.size(36.dp), + shape = CircleShape, + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (artistAvatar != null) { + AsyncImage( + model = artistAvatar, + contentDescription = "Artist", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + // Logo de AirBeats con estilo mejorado + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.airbeats), + contentDescription = "AirBeats", + modifier = Modifier + .size(20.dp) + .alpha(0.9f), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary) + ) + } + } + } + } + } + } + } + } + + Spacer(Modifier.width(16.dp)) + + // Información de la canción + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = song?.song?.title ?: "No song playing", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(Modifier.height(6.dp)) + + Text( + text = song?.artists?.joinToString { it.name } ?: "Unknown Artist", + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + song?.album?.title?.let { albumTitle -> + Spacer(Modifier.height(4.dp)) + Text( + text = albumTitle, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } + + // Barra de progreso mejorada + if (song != null) { + Spacer(Modifier.height(20.dp)) + + EnhancedProgressBar( + position = position, + duration = duration, + isPlaying = isPlaying, + sliderStyle = sliderStyle + ) + } + + Spacer(Modifier.height(20.dp)) + + // Botones de acción mejorados + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Botón YouTube Music mejorado + FilledTonalButton( + enabled = song != null, + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + "https://music.youtube.com/watch?v=${song?.id}".toUri() + ) + context.startActivity(intent) + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = "YouTube Music", + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("YouTube Music", maxLines = 1, fontWeight = FontWeight.Medium) + } + + // Botón AirBeats mejorado + OutlinedButton( + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + "https://github.com/darkxvenom/airbeats".toUri() + ) + context.startActivity(intent) + }, + modifier = Modifier.weight(1f), + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) { + Box( + modifier = Modifier + .size(18.dp) + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary + ) + ), + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.airbeats), + contentDescription = "AirBeats", + modifier = Modifier.size(12.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary) + ) + } + Spacer(Modifier.width(8.dp)) + Text("AirBeats", maxLines = 1, fontWeight = FontWeight.Medium) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnhancedProgressBar( + position: Long, + duration: Long, + isPlaying: Boolean, + sliderStyle: SliderStyle +) { + Column(modifier = Modifier.fillMaxWidth()) { + when (sliderStyle) { + SliderStyle.DEFAULT -> { + Slider( + value = position.toFloat(), + valueRange = 0f..duration.toFloat().coerceAtLeast(1f), + onValueChange = {}, + enabled = false, + colors = SliderDefaults.colors( + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, + disabledActiveTrackColor = MaterialTheme.colorScheme.primary, + disabledInactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, + disabledThumbColor = MaterialTheme.colorScheme.primary + ) + ) + } + + SliderStyle.SQUIGGLY -> { + SquigglySlider( + value = position.toFloat(), + valueRange = 0f..duration.toFloat().coerceAtLeast(1f), + onValueChange = {}, + enabled = false, + colors = SliderDefaults.colors( + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, + disabledActiveTrackColor = MaterialTheme.colorScheme.primary, + disabledInactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, + disabledThumbColor = MaterialTheme.colorScheme.primary + ), + squigglesSpec = SquigglySlider.SquigglesSpec( + amplitude = if (isPlaying) 2.dp else 0.dp, + strokeWidth = 3.dp + ) + ) + } + + SliderStyle.SLIM -> { + LinearProgressIndicator( + progress = { (position.toFloat() / duration.toFloat().coerceAtLeast(1f)) }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + } + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = makeTimeString(position), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + Text( + text = makeTimeString(duration), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/PlayerSettings.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/PlayerSettings.kt new file mode 100644 index 00000000..dd6bf0b6 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/PlayerSettings.kt @@ -0,0 +1,161 @@ +package com.darkxvenom.airbeats.ui.screens.settings + + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AudioNormalizationKey +import com.darkxvenom.airbeats.constants.AudioQuality +import com.darkxvenom.airbeats.constants.AudioQualityKey +import com.darkxvenom.airbeats.constants.AutoLoadMoreKey +import com.darkxvenom.airbeats.constants.AutoSkipNextOnErrorKey +import com.darkxvenom.airbeats.constants.PersistentQueueKey +import com.darkxvenom.airbeats.constants.SimilarContent +import com.darkxvenom.airbeats.constants.SkipSilenceKey +import com.darkxvenom.airbeats.constants.StopMusicOnTaskClearKey +import com.darkxvenom.airbeats.ui.component.EnumListPreference +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.PreferenceGroupTitle +import com.darkxvenom.airbeats.ui.component.SettingsGeneralCategory +import com.darkxvenom.airbeats.ui.component.SettingsPage +import com.darkxvenom.airbeats.ui.component.SwitchPreference +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.rememberEnumPreference +import com.darkxvenom.airbeats.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayerSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val (audioQuality, onAudioQualityChange) = rememberEnumPreference( + AudioQualityKey, + defaultValue = AudioQuality.AUTO + ) + val (persistentQueue, onPersistentQueueChange) = rememberPreference( + PersistentQueueKey, + defaultValue = true + ) + val (skipSilence, onSkipSilenceChange) = rememberPreference( + SkipSilenceKey, + defaultValue = false + ) + val (audioNormalization, onAudioNormalizationChange) = rememberPreference( + AudioNormalizationKey, + defaultValue = true + ) + val (autoLoadMore, onAutoLoadMoreChange) = rememberPreference( + AutoLoadMoreKey, + defaultValue = true + ) + val (similarContentEnabled, similarContentEnabledChange) = rememberPreference( + key = SimilarContent, + defaultValue = true + ) + val (autoSkipNextOnError, onAutoSkipNextOnErrorChange) = rememberPreference( + AutoSkipNextOnErrorKey, + defaultValue = false + ) + val (stopMusicOnTaskClear, onStopMusicOnTaskClearChange) = rememberPreference( + StopMusicOnTaskClearKey, + defaultValue = false + ) + + SettingsPage( + title = stringResource(R.string.player_and_audio), + navController = navController, + scrollBehavior = scrollBehavior + ) { + SettingsGeneralCategory( + title = stringResource(R.string.player), + items = listOf( + {EnumListPreference( + title = { Text(stringResource(R.string.audio_quality)) }, + icon = { Icon(painterResource(R.drawable.graphic_eq), null) }, + selectedValue = audioQuality, + onValueSelected = onAudioQualityChange, + valueText = { + when (it) { + AudioQuality.AUTO -> stringResource(R.string.audio_quality_auto) + AudioQuality.HIGH -> stringResource(R.string.audio_quality_high) + AudioQuality.LOW -> stringResource(R.string.audio_quality_low) + } + } + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.skip_silence)) }, + icon = { Icon(painterResource(R.drawable.fast_forward), null) }, + checked = skipSilence, + onCheckedChange = onSkipSilenceChange + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.audio_normalization)) }, + icon = { Icon(painterResource(R.drawable.volume_up), null) }, + checked = audioNormalization, + onCheckedChange = onAudioNormalizationChange + )}, + ) + ) + + SettingsGeneralCategory( + title = stringResource(R.string.queue), + items = listOf( + {SwitchPreference( + title = { Text(stringResource(R.string.persistent_queue)) }, + description = stringResource(R.string.persistent_queue_desc), + icon = { Icon(painterResource(R.drawable.queue_music), null) }, + checked = persistentQueue, + onCheckedChange = onPersistentQueueChange + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.auto_load_more)) }, + description = stringResource(R.string.auto_load_more_desc), + icon = { Icon(painterResource(R.drawable.playlist_add), null) }, + checked = autoLoadMore, + onCheckedChange = onAutoLoadMoreChange + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.enable_similar_content)) }, + description = stringResource(R.string.similar_content_desc), + icon = { Icon(painterResource(R.drawable.similar), null) }, + checked = similarContentEnabled, + onCheckedChange = similarContentEnabledChange, + )}, + + {SwitchPreference( + title = { Text(stringResource(R.string.auto_skip_next_on_error)) }, + description = stringResource(R.string.auto_skip_next_on_error_desc), + icon = { Icon(painterResource(R.drawable.skip_next), null) }, + checked = autoSkipNextOnError, + onCheckedChange = onAutoSkipNextOnErrorChange + )}, + ) + ) + + SettingsGeneralCategory( + title = stringResource(R.string.misc), + items = listOf( + {SwitchPreference( + title = { Text(stringResource(R.string.stop_music_on_task_clear)) }, + icon = { Icon(painterResource(R.drawable.clear_all), null) }, + checked = stopMusicOnTaskClear, + onCheckedChange = onStopMusicOnTaskClearChange + )}, + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/PrivacySettings.kt new file mode 100644 index 00000000..9beb28ce --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/PrivacySettings.kt @@ -0,0 +1,200 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +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.WindowInsetsSides +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.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.darkxvenom.airbeats.LocalDatabase +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.DisableScreenshotKey +import com.darkxvenom.airbeats.constants.PauseListenHistoryKey +import com.darkxvenom.airbeats.constants.PauseSearchHistoryKey +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.PreferenceEntry +import com.darkxvenom.airbeats.ui.component.SettingsGeneralCategory +import com.darkxvenom.airbeats.ui.component.SettingsPage +import com.darkxvenom.airbeats.ui.component.SwitchPreference +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.utils.rememberPreference + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivacySettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val database = LocalDatabase.current + val (pauseListenHistory, onPauseListenHistoryChange) = rememberPreference( + key = PauseListenHistoryKey, + defaultValue = false + ) + val (pauseSearchHistory, onPauseSearchHistoryChange) = rememberPreference( + key = PauseSearchHistoryKey, + defaultValue = false + ) + val (disableScreenshot, onDisableScreenshotChange) = rememberPreference( + key = DisableScreenshotKey, + defaultValue = false + ) + + var showClearListenHistoryDialog by remember { mutableStateOf(false) } + var showClearSearchHistoryDialog by remember { mutableStateOf(false) } + + SettingsPage( + title = stringResource(R.string.privacy), + navController = navController, + scrollBehavior = scrollBehavior, + ) { + SettingsGeneralCategory( + title = stringResource(R.string.listen_history), + items = listOf( + {SwitchPreference( + title = { Text(stringResource(R.string.pause_listen_history)) }, + icon = { Icon(painterResource(R.drawable.history), null) }, + checked = pauseListenHistory, + onCheckedChange = onPauseListenHistoryChange + )}, + + {PreferenceEntry( + title = { Text(stringResource(R.string.clear_listen_history)) }, + icon = { Icon(painterResource(R.drawable.delete_history), null) }, + onClick = { showClearListenHistoryDialog = true } + )}, + ) + ) + SettingsGeneralCategory( + title = stringResource(R.string.search_history), + items = listOf( + {SwitchPreference( + title = { Text(stringResource(R.string.pause_search_history)) }, + icon = { Icon(painterResource(R.drawable.search_off), null) }, + checked = pauseSearchHistory, + onCheckedChange = onPauseSearchHistoryChange + )}, + + {PreferenceEntry( + title = { Text(stringResource(R.string.clear_search_history)) }, + icon = { Icon(painterResource(R.drawable.clear_all), null) }, + onClick = { showClearSearchHistoryDialog = true } + )}, + ) + ) + + SettingsGeneralCategory( + title = stringResource(R.string.misc), + items = listOf( + {SwitchPreference( + title = { Text(stringResource(R.string.disable_screenshot)) }, + description = stringResource(R.string.disable_screenshot_desc), + icon = { Icon(painterResource(R.drawable.screenshot), null) }, + checked = disableScreenshot, + onCheckedChange = onDisableScreenshotChange + )}, + ) + ) + } + + + if (showClearListenHistoryDialog) { + ConfirmationDialog2( + title = stringResource(R.string.clear_listen_history), + message = stringResource(R.string.clear_listen_history_confirm), + onDismiss = { showClearListenHistoryDialog = false }, + onConfirm = { + showClearListenHistoryDialog = false + database.query { clearListenHistory() } + } + ) + } + + if (showClearSearchHistoryDialog) { + ConfirmationDialog2( + title = stringResource(R.string.clear_search_history), + message = stringResource(R.string.clear_search_history_confirm), + onDismiss = { showClearSearchHistoryDialog = false }, + onConfirm = { + showClearSearchHistoryDialog = false + database.query { clearSearchHistory() } + } + ) + } +} + +@Composable +private fun ConfirmationDialog2( + title: String, + message: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium + ) + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + text = stringResource(android.R.string.ok), + fontWeight = FontWeight.Medium + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(android.R.string.cancel), + fontWeight = FontWeight.Medium + ) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + textContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 00000000..c3355c7e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,1513 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +import android.annotation.SuppressLint +import android.app.DownloadManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.* +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.drawBehind +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.core.net.toUri +import androidx.navigation.NavController +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.darkxvenom.airbeats.innertube.utils.parseCookieString +import com.darkxvenom.airbeats.BuildConfig +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.AccountNameKey +import com.darkxvenom.airbeats.constants.InnerTubeCookieKey +import com.darkxvenom.airbeats.ui.component.AvatarPreferenceManager +import com.darkxvenom.airbeats.ui.component.AvatarSelection +import com.darkxvenom.airbeats.ui.component.ChangelogScreen +import com.darkxvenom.airbeats.utils.rememberPreference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.net.URL + +// ==================== DIVIDER COMPONENT ==================== + +@Composable +fun SettingsDivider( + modifier: Modifier = Modifier, + color: Color = Color.White.copy(alpha = 0.1f), + thickness: Dp = 0.5.dp +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(thickness) + .background(color) + ) +} + +// ==================== SETTINGS CATEGORY COMPONENTS ==================== + +data class SettingsCategoryItem( + val icon: androidx.compose.ui.graphics.painter.Painter, + val title: @Composable () -> Unit, + val trailingContent: @Composable (() -> Unit)? = null, + val onClick: () -> Unit +) + +@Composable +fun SettingsCategory( + title: String, + items: List, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = title, + modifier = Modifier.padding(start = 20.dp, bottom = 8.dp), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp + ), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .shadow( + elevation = 4.dp, + shape = RoundedCornerShape(28.dp), + clip = false + ), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.8f) + ) + ) { + Column( + modifier = Modifier.padding(vertical = 8.dp) + ) { + items.forEachIndexed { index, item -> + SettingsCategoryItemContent( + item = item, + isLast = index == items.lastIndex + ) + } + } + } + } +} + +@Composable +fun SettingsCategoryItemContent( + item: SettingsCategoryItem, + isLast: Boolean +) { + var isPressed by remember { mutableStateOf(false) } + + Box( + modifier = Modifier + .fillMaxWidth() + .scale(if (isPressed) 0.98f else 1f) + .clip(RoundedCornerShape(16.dp)) + .background( + brush = Brush.horizontalGradient( + colors = if (isPressed) { + listOf( + Color.White.copy(alpha = 0.1f), + Color.White.copy(alpha = 0.05f) + ) + } else { + listOf( + Color.Transparent, + Color.Transparent + ) + } + ) + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + isPressed = true + item.onClick() + isPressed = false + } + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon container + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = item.icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + // Title + Box(modifier = Modifier.weight(1f)) { + item.title() + } + + // Trailing content + if (item.trailingContent != null) { + item.trailingContent() + } else { + Icon( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(18.dp) + ) + } + } + } + + if (!isLast) { + SettingsDivider( + modifier = Modifier.padding(start = 72.dp, end = 16.dp) + ) + } +} + +// ==================== GLASS CARD COMPONENT ==================== + +@Composable +fun GlassCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Card( + modifier = modifier + .shadow( + elevation = 4.dp, + shape = RoundedCornerShape(28.dp), + clip = false + ), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.8f) + ) + ) { + content() + } +} + +// ==================== WATER DROP BUTTON COMPONENTS ==================== + +@Composable +fun WaterDropButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: @Composable (() -> Unit)? = null, + text: String? = null, + colors: List = listOf( + Color(0xFF6C5CE7), + Color(0xFFA463F5), + Color(0xFFC45AF0) + ) +) { + var isPressed by remember { mutableStateOf(false) } + + Box( + modifier = modifier + .clip(RoundedCornerShape(30.dp)) + .scale(if (isPressed) 0.95f else 1f) + .shadow( + elevation = if (isPressed) 2.dp else 8.dp, + shape = RoundedCornerShape(30.dp), + clip = false + ) + .background( + brush = Brush.linearGradient( + colors = colors, + start = Offset(0f, 0f), + end = Offset(100f, 100f) + ), + shape = RoundedCornerShape(30.dp) + ) + .drawBehind { + // Water drop effect - bottom highlight + drawRect( + color = Color.White.copy(alpha = 0.2f), + topLeft = Offset(size.width * 0.2f, size.height * 0.7f), + size = Size(size.width * 0.6f, size.height * 0.3f), + blendMode = BlendMode.Screen + ) + // Top highlight + drawCircle( + color = Color.White.copy(alpha = 0.3f), + radius = size.minDimension * 0.15f, + center = Offset(size.width * 0.3f, size.height * 0.3f), + blendMode = BlendMode.Screen + ) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + isPressed = true + onClick() + isPressed = false + }, + contentAlignment = Alignment.Center + ) { + Row( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) { + icon() + if (text != null) Spacer(modifier = Modifier.width(8.dp)) + } + if (text != null) { + Text( + text = text, + color = Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp + ) + } + } + } +} + +@Composable +fun WaterDropIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: @Composable () -> Unit +) { + var isPressed by remember { mutableStateOf(false) } + + Box( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .scale(if (isPressed) 0.9f else 1f) + .shadow( + elevation = if (isPressed) 2.dp else 6.dp, + shape = CircleShape, + clip = false + ) + .background( + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.8f), + CircleShape + ) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + shape = CircleShape + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + isPressed = true + onClick() + isPressed = false + }, + contentAlignment = Alignment.Center + ) { + icon() + } +} + +// ==================== ORIGINAL FUNCTIONS ==================== + +@SuppressLint("ObsoleteSdkInt") +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +fun getAppVersion(context: Context): String { + return try { + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.packageManager.getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(0) + ) + } else { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo( + context.packageName, + 0 + ) + } + packageInfo.versionName ?: "Unknown" + } catch (e: PackageManager.NameNotFoundException) { + "Unknown" + } +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +fun VersionCard(uriHandler: UriHandler) { + val context = LocalContext.current + val appVersion = remember { getAppVersion(context) } + + Spacer(Modifier.height(16.dp)) + + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Text( + text = stringResource(R.string.app_info), + modifier = Modifier.padding(start = 4.dp, bottom = 8.dp), + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp + ), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) + ) + + GlassCard( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(vertical = 8.dp) + ) { + // Version item + SettingsCategoryItemContent( + item = SettingsCategoryItem( + icon = painterResource(R.drawable.info), + title = { + Column { + Text( + text = stringResource(R.string.Version), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = appVersion, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace + ) + } + }, + trailingContent = { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + }, + onClick = { uriHandler.openUri("https://github.com/d0x-dev/AirBeats/releases/latest") } + ), + isLast = false + ) + + SettingsDivider(modifier = Modifier.padding(start = 72.dp, end = 16.dp)) + + // Website item + SettingsCategoryItemContent( + item = SettingsCategoryItem( + icon = painterResource(R.drawable.resource_public), + title = { + Text( + text = "Official Website", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + }, + trailingContent = { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + }, + onClick = { uriHandler.openUri("https://airbeats.stormx.pw") } + ), + isLast = true + ) + } + } + } +} + +@Composable +fun UpdateCard(latestVersion: String = "") { + val context = LocalContext.current + var showUpdateCard by remember { mutableStateOf(false) } + var currentLatestVersion by remember { mutableStateOf(latestVersion) } + var showDownloadDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val newVersion = checkForUpdates() + if (newVersion != null && isNewerVersion(newVersion, BuildConfig.VERSION_NAME)) { + showUpdateCard = true + currentLatestVersion = newVersion + } + } + + if (showDownloadDialog) { + UpdateDownloadDialog( + latestVersion = currentLatestVersion, + onDismiss = { showDownloadDialog = false } + ) + } + + if (showUpdateCard) { + Spacer(Modifier.height(16.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + GlassCard( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showDownloadDialog = true } + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Update icon with animation + val infiniteTransition = rememberInfiniteTransition(label = "update") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "scale" + ) + + Box( + modifier = Modifier + .size(48.dp) + .scale(scale) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.update), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(28.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.NewVersion) + ": $currentLatestVersion", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(R.string.tap_to_update), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(24.dp) + ) + } + } + } + } +} + +@Composable +fun UpdateDownloadDialog( + latestVersion: String, + onDismiss: () -> Unit +) { + val uriHandler = LocalUriHandler.current + var downloadStatus by remember { mutableStateOf(DownloadStatus.NOT_STARTED) } + + Dialog(onDismissRequest = { + if (downloadStatus != DownloadStatus.REDIRECTING) { + onDismiss() + } + }) { + GlassCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.update_version, latestVersion), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + when (downloadStatus) { + DownloadStatus.NOT_STARTED -> { + Text( + stringResource(R.string.download_question), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + WaterDropButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + text = stringResource(R.string.cancel), + colors = listOf( + Color(0xFF2A2A2A), + Color(0xFF3A3A3A), + Color(0xFF2A2A2A) + ) + ) + + WaterDropButton( + onClick = { + downloadStatus = DownloadStatus.REDIRECTING + val downloadUrl = "https://github.com/d0x-dev/AirBeats/releases/download/$latestVersion/AirBeats_v${latestVersion}_signed.apk" + uriHandler.openUri(downloadUrl) + downloadStatus = DownloadStatus.COMPLETED + onDismiss() + }, + modifier = Modifier.weight(1f), + text = stringResource(R.string.download), + colors = listOf( + Color(0xFF6C5CE7), + Color(0xFFA463F5), + Color(0xFFC45AF0) + ) + ) + } + } + + DownloadStatus.REDIRECTING -> { + Text( + stringResource(R.string.opening_browser), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + strokeWidth = 4.dp, + color = MaterialTheme.colorScheme.primary + ) + } + + DownloadStatus.COMPLETED -> { + Text( + stringResource(R.string.download_started), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + WaterDropButton( + onClick = onDismiss, + text = stringResource(R.string.close), + modifier = Modifier.fillMaxWidth() + ) + } + + DownloadStatus.ERROR -> { + Text( + stringResource(R.string.download_errorup), + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + WaterDropButton( + onClick = onDismiss, + text = stringResource(R.string.close), + modifier = Modifier.fillMaxWidth(), + colors = listOf( + Color(0xFFCF6679), + Color(0xFFB0003A), + Color(0xFFCF6679) + ) + ) + } + } + } + } + } +} + +enum class DownloadStatus { + NOT_STARTED, + REDIRECTING, + COMPLETED, + ERROR +} + +suspend fun checkForUpdates(): String? = withContext(Dispatchers.IO) { + try { + val url = URL("https://api.github.com/repos/d0x-dev/AirBeats/releases/latest") + val connection = url.openConnection() + connection.connect() + val json = connection.getInputStream().bufferedReader().use { it.readText() } + val jsonObject = JSONObject(json) + return@withContext jsonObject.getString("tag_name") + } catch (e: Exception) { + e.printStackTrace() + return@withContext null + } +} + +fun isNewerVersion(remoteVersion: String, currentVersion: String): Boolean { + fun normalize(version: String): List { + return version + .replace(Regex("[^0-9.]"), "") + .split(".") + .map { it.toIntOrNull() ?: 0 } + } + + val remote = normalize(remoteVersion) + val current = normalize(currentVersion) + + for (i in 0 until maxOf(remote.size, current.size)) { + val r = remote.getOrNull(i) ?: 0 + val c = current.getOrNull(i) ?: 0 + if (r > c) return true + if (r < c) return false + } + + return false +} + +// ==================== MAIN SETTINGS SCREEN ==================== +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + latestVersion: Long, + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { + val uriHandler = LocalUriHandler.current + var showTranslateDialog by remember { mutableStateOf(false) } + var showChangelogSheet by remember { mutableStateOf(false) } + + // Get player connection for album artwork + val playerConnection = LocalPlayerConnection.current + val mediaMetadata by playerConnection?.mediaMetadata?.collectAsState() + ?: remember { mutableStateOf(null) } + + Box(modifier = Modifier.fillMaxSize()) { + // 🎵 BLUR BACKGROUND + val artworkUrl = mediaMetadata?.thumbnailUrl + + artworkUrl?.let { imageUrl -> + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(90.dp) + ) + + val isDarkTheme = + MaterialTheme.colorScheme.background.luminance() < 0.5f + + val overlayBrush = if (isDarkTheme) { + Brush.verticalGradient( + listOf( + Color.Black.copy(alpha = 0.2f), + Color.Black.copy(alpha = 0.5f), + Color.Black.copy(alpha = 0.85f) + ) + ) + } else { + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.25f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + MaterialTheme.colorScheme.background.copy(alpha = 0.85f) + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(overlayBrush) + ) + } + + // Main Scaffold with TopAppBar that scrolls + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = Color.Transparent, + topBar = { + // U-Shaped TopAppBar that scrolls with content + TopAppBar( + title = { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.settings), + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + }, + navigationIcon = { + Spacer(modifier = Modifier.width(48.dp)) + }, + actions = { + Spacer(modifier = Modifier.width(48.dp)) + }, + modifier = Modifier + .clip( + RoundedCornerShape( + bottomStart = 30.dp, + bottomEnd = 30.dp + ) + ) + .background( + Brush.verticalGradient( + listOf( + MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + MaterialTheme.colorScheme.surface.copy(alpha = 0.65f) + ) + ) + ) + .border( + width = 0.6.dp, + brush = Brush.horizontalGradient( + listOf( + Color.White.copy(alpha = 0.3f), + Color.White.copy(alpha = 0.1f), + Color.White.copy(alpha = 0.3f) + ) + ), + shape = RoundedCornerShape( + bottomStart = 30.dp, + bottomEnd = 30.dp + ) + ), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + scrolledContainerColor = Color.Transparent + ), + scrollBehavior = scrollBehavior + ) + } + ) { innerPadding -> + // Content with proper padding from Scaffold + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) // VERY IMPORTANT + .verticalScroll(rememberScrollState()) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current.only( + WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom + ) + ) + ) + + { + // Add a small top padding to separate from header + + val context = LocalContext.current + val avatarManager = remember { AvatarPreferenceManager(context) } + val currentSelection by avatarManager.getAvatarSelection.collectAsState(initial = AvatarSelection.Default) + val accountName by rememberPreference(AccountNameKey, "") + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + // Profile Section + ProfileSection( + isLoggedIn = isLoggedIn, + accountName = accountName, + currentSelection = currentSelection + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Settings Categories + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // General Settings + SettingsCategory( + title = stringResource(R.string.general_settings), + items = listOf( + SettingsCategoryItem( + icon = painterResource(R.drawable.palette), + title = { + Text( + stringResource(R.string.appearance), + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { navController.navigate("settings/appearance") } + ), + SettingsCategoryItem( + icon = painterResource(R.drawable.person), + title = { + Text( + stringResource(R.string.account), + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { navController.navigate("settings/account") } + ), + SettingsCategoryItem( + icon = painterResource(R.drawable.language), + title = { + Text( + stringResource(R.string.content), + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { navController.navigate("settings/content") } + ), + SettingsCategoryItem( + icon = painterResource(R.drawable.play), + title = { + Text( + stringResource(R.string.player_and_audio), + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { navController.navigate("settings/player") } + ), + SettingsCategoryItem( + icon = painterResource(R.drawable.storage), + title = { + Text( + stringResource(R.string.storage), + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { navController.navigate("settings/storage") } + ), + SettingsCategoryItem( + icon = painterResource(R.drawable.security), + title = { + Text( + stringResource(R.string.privacy), + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { navController.navigate("settings/privacy") } + ), + SettingsCategoryItem( + icon = painterResource(R.drawable.restore), + title = { + Text( + stringResource(R.string.backup_restore), + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { navController.navigate("settings/backup_restore") } + ) + ) + ) + + // About & Community + SettingsCategory( + title = stringResource(R.string.community), + items = listOf( + SettingsCategoryItem( + icon = painterResource(R.drawable.info), + title = { + Text( + stringResource(R.string.about), + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { navController.navigate("settings/about") } + ), + SettingsCategoryItem( + icon = painterResource(R.drawable.schedule), + title = { + Text( + stringResource(R.string.Changelog), + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { showChangelogSheet = true } + ), + SettingsCategoryItem( + icon = painterResource(R.drawable.telegram), + title = { + Text( + "Telegram Bot", + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { uriHandler.openUri("https://t.me/Stormxmusicrobot") } + ) + ) + ) + } + + // Update Card + UpdateCard() + + // Version Card + VersionCard(uriHandler) + + Spacer(Modifier.height(32.dp)) + } + } + + // Dialogs and Bottom Sheets + if (showTranslateDialog) { + Dialog(onDismissRequest = { showTranslateDialog = false }) { + GlassCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.Redirección), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.poeditor_redirect), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + WaterDropButton( + onClick = { showTranslateDialog = false }, + modifier = Modifier.weight(1f), + text = stringResource(R.string.cancel), + colors = listOf( + Color(0xFF2A2A2A), + Color(0xFF3A3A3A), + Color(0xFF2A2A2A) + ) + ) + + WaterDropButton( + onClick = { + showTranslateDialog = false + uriHandler.openUri("https://poeditor.com/join/project/208BwCVazA") + }, + modifier = Modifier.weight(1f), + text = "OK", + colors = listOf( + Color(0xFF6C5CE7), + Color(0xFFA463F5), + Color(0xFFC45AF0) + ) + ) + } + } + } + } + } + + if (showChangelogSheet) { + ModalBottomSheet( + onDismissRequest = { showChangelogSheet = false }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + dragHandle = { + Box( + modifier = Modifier + .padding(vertical = 12.dp) + .width(40.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + ) + } + ) { + GlassCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(20.dp) + ) { + Text( + text = stringResource(R.string.Changelog), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + ChangelogScreen() + + Spacer(Modifier.height(24.dp)) + + WaterDropButton( + onClick = { showChangelogSheet = false }, + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.close), + colors = listOf( + Color(0xFF6C5CE7), + Color(0xFFA463F5), + Color(0xFFC45AF0) + ) + ) + } + } + } + } + } +} + +@Composable +fun ProfileSection( + isLoggedIn: Boolean, + accountName: String, + currentSelection: AvatarSelection +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 0.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isLoggedIn) { + var imageLoadError by remember { mutableStateOf(false) } + var isImageLoading by remember { mutableStateOf(false) } + + // Avatar with glow effect + Box( + contentAlignment = Alignment.Center + ) { + // Outer glow + Box( + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + Color.Transparent + ) + ) + ) + .blur(20.dp) + ) + + // Avatar container + Box( + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.surfaceContainer + ) + .border( + width = 3.dp, + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.4f) + ) + ), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + when { + currentSelection is AvatarSelection.Custom && !imageLoadError -> { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data((currentSelection as AvatarSelection.Custom).uri.toUri()) + .crossfade(true) + .listener( + onStart = { isImageLoading = true }, + onSuccess = { _, _ -> + isImageLoading = false + imageLoadError = false + }, + onError = { _, _ -> + isImageLoading = false + imageLoadError = true + } + ) + .build(), + contentDescription = "Avatar de $accountName", + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + if (isImageLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + ), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + currentSelection is AvatarSelection.DiceBear && !imageLoadError -> { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data((currentSelection as AvatarSelection.DiceBear).url) + .crossfade(true) + .listener( + onStart = { isImageLoading = true }, + onSuccess = { _, _ -> + isImageLoading = false + imageLoadError = false + }, + onError = { _, _ -> + isImageLoading = false + imageLoadError = true + } + ) + .build(), + contentDescription = "Avatar DiceBear de $accountName", + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + if (isImageLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + ), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + else -> { + val initials = remember(accountName) { + val cleanName = accountName.replace("@", "").trim() + when { + cleanName.isEmpty() -> "?" + cleanName.contains(" ") -> { + val parts = cleanName.split(" ") + "${parts.first().firstOrNull()?.uppercase() ?: ""}${ + parts.last().firstOrNull()?.uppercase() ?: "" + }" + } + else -> cleanName.take(2).uppercase() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary + ), + start = Offset(0f, 0f), + end = Offset(100f, 100f) + ) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = initials, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + // Online indicator with pulse animation + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val pulseAlpha by infiniteTransition.animateFloat( + initialValue = 0.5f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + Box( + modifier = Modifier + .size(24.dp) + .offset(x = 32.dp, y = 32.dp) + .clip(CircleShape) + .background( + brush = Brush.radialGradient( + colors = listOf( + Color(0xFF00FF00).copy(alpha = pulseAlpha), + Color(0xFF00FF00).copy(alpha = 0.3f) + ) + ) + ) + .border( + width = 2.dp, + color = Color.White, + shape = CircleShape + ) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Username with animation + AnimatedContent( + targetState = accountName.replace("@", "").takeIf { it.isNotBlank() } ?: "", + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "username" + ) { name -> + Text( + text = name, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + } + + } else { + // Not logged in state + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Logo with glow effect + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background( + brush = Brush.radialGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + Color.Transparent + ) + ) + ) + .blur(20.dp) + ) + + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background( + MaterialTheme.colorScheme.surfaceContainer + ) + .border( + width = 1.dp, + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary.copy(alpha = 0.6f), + MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) + ), + shape = CircleShape + ) + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.airbeats_monochrome), + contentDescription = "Logo de AirBeats", + modifier = Modifier.fillMaxSize(), + tint = MaterialTheme.colorScheme.primary + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary + ) + ) { + append("Air") + } + + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.tertiary + ) + ) { + append("Beats") + } + }, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = "Dev By DarkXVenom 亗", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/StorageSettings.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/StorageSettings.kt new file mode 100644 index 00000000..10f518b8 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/screens/settings/StorageSettings.kt @@ -0,0 +1,672 @@ +package com.darkxvenom.airbeats.ui.screens.settings + +import androidx.compose.animation.core.animateFloatAsState +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.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.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.annotation.ExperimentalCoilApi +import coil.compose.AsyncImage +import coil.imageLoader +import com.darkxvenom.airbeats.LocalPlayerAwareWindowInsets +import com.darkxvenom.airbeats.LocalPlayerConnection +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.constants.MaxImageCacheSizeKey +import com.darkxvenom.airbeats.constants.MaxSongCacheSizeKey +import com.darkxvenom.airbeats.constants.ThumbnailCornerRadius +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.extensions.tryOrNull +import com.darkxvenom.airbeats.ui.component.IconButton +import com.darkxvenom.airbeats.ui.component.ListPreference +import com.darkxvenom.airbeats.ui.utils.backToMain +import com.darkxvenom.airbeats.ui.utils.formatFileSize +import com.darkxvenom.airbeats.utils.rememberPreference +import com.darkxvenom.airbeats.viewmodels.HistoryViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalCoilApi::class, ExperimentalMaterial3Api::class) +@Composable +fun StorageSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: HistoryViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val imageDiskCache = context.imageLoader.diskCache ?: return + val playerCache = LocalPlayerConnection.current?.service?.playerCache ?: return + val downloadCache = LocalPlayerConnection.current?.service?.downloadCache ?: return + + val coroutineScope = rememberCoroutineScope() + val (maxImageCacheSize, onMaxImageCacheSizeChange) = rememberPreference( + key = MaxImageCacheSizeKey, + defaultValue = -1 + ) + val (maxSongCacheSize, onMaxSongCacheSizeChange) = rememberPreference( + key = MaxSongCacheSizeKey, + defaultValue = -1 + ) + + var imageCacheSize by remember { mutableLongStateOf(imageDiskCache.size) } + var playerCacheSize by remember { mutableLongStateOf(tryOrNull { playerCache.cacheSpace } ?: 0) } + var downloadCacheSize by remember { mutableLongStateOf(tryOrNull { downloadCache.cacheSpace } ?: 0) } + + val animatedImageCacheSize by animateFloatAsState( + targetValue = if (maxImageCacheSize == -1) 0f + else (imageCacheSize.toFloat() / (maxImageCacheSize * 1024 * 1024L)).coerceIn(0f, 1f), + label = "imageCacheProgress", + ) + + val animatedPlayerCacheSize by animateFloatAsState( + targetValue = if (maxSongCacheSize == -1) 0f + else (playerCacheSize.toFloat() / (maxSongCacheSize * 1024 * 1024L)).coerceIn(0f, 1f), + label = "playerCacheProgress", + ) + val animatedDownloadCacheSize by animateFloatAsState( + targetValue = if (downloadCacheSize == 0L) 0f else 1f, + label = "downloadCacheProgress", + ) + + var showCachedSongsSheet by remember { mutableStateOf(false) } + + LaunchedEffect(imageDiskCache) { + while (isActive) { + delay(500) + imageCacheSize = imageDiskCache.size + } + } + LaunchedEffect(playerCache) { + while (isActive) { + delay(500) + playerCacheSize = tryOrNull { playerCache.cacheSpace } ?: 0 + } + } + LaunchedEffect(downloadCache) { + while (isActive) { + delay(500) + downloadCacheSize = tryOrNull { downloadCache.cacheSpace } ?: 0 + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Descargas + StorageCard( + title = stringResource(R.string.downloaded_songs), + icon = R.drawable.download, + usedSize = downloadCacheSize, + maxSize = null, + progress = if (downloadCacheSize > 0) animatedDownloadCacheSize else 0f, + onClearClick = { + coroutineScope.launch(Dispatchers.IO) { + try { + downloadCache.keys.toList().forEach { key -> + tryOrNull { downloadCache.removeResource(key) } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + }, + onManageClick = null + ) + + // Caché de canciones + StorageCard( + title = stringResource(R.string.song_cache), + icon = R.drawable.music_note, + usedSize = playerCacheSize, + maxSize = if (maxSongCacheSize == -1) -1L else maxSongCacheSize * 1024 * 1024L, + progress = animatedPlayerCacheSize, + onClearClick = { + coroutineScope.launch(Dispatchers.IO) { + try { + playerCache.keys.toList().forEach { key -> + tryOrNull { playerCache.removeResource(key) } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + }, + onManageClick = { showCachedSongsSheet = true }, + extraContent = { + ListPreference( + title = { Text(stringResource(R.string.max_cache_size)) }, + selectedValue = maxSongCacheSize, + values = listOf(-1, 128, 256, 512, 1024, 2048, 4096, 8192), + valueText = { + if (it == -1) stringResource(R.string.unlimited) + else formatFileSize(it * 1024 * 1024L) + }, + onValueSelected = onMaxSongCacheSizeChange, + ) + } + ) + + // Caché de imágenes + StorageCard( + title = stringResource(R.string.image_cache), + icon = R.drawable.image, + usedSize = imageCacheSize, + maxSize = if (maxImageCacheSize == -1) -1L else maxImageCacheSize * 1024 * 1024L, + progress = animatedImageCacheSize, + onClearClick = { + coroutineScope.launch(Dispatchers.IO) { + try { + imageDiskCache.clear() + } catch (e: Exception) { + e.printStackTrace() + } + } + }, + onManageClick = null, + extraContent = { + ListPreference( + title = { Text(stringResource(R.string.max_cache_size)) }, + selectedValue = maxImageCacheSize, + values = listOf(-1, 128, 256, 512, 1024, 2048, 4096, 8192), + valueText = { + if (it == -1) stringResource(R.string.unlimited) + else formatFileSize(it * 1024 * 1024L) + }, + onValueSelected = onMaxImageCacheSizeChange, + ) + } + ) + } + + TopAppBar( + title = { Text(stringResource(R.string.storage)) }, + navigationIcon = { + IconButton( + onClick = navController::navigateUp, + onLongClick = navController::backToMain, + ) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null, + ) + } + }, + ) + } + + // Bottom Sheet para gestionar canciones en caché + if (showCachedSongsSheet) { + CachedSongsBottomSheet( + playerCache = playerCache, + viewModel = viewModel, + onDismiss = { showCachedSongsSheet = false } + ) + } +} + +@Composable +private fun StorageCard( + title: String, + icon: Int, + usedSize: Long, + maxSize: Long?, + progress: Float, + onClearClick: () -> Unit, + onManageClick: (() -> Unit)?, + extraContent: (@Composable () -> Unit)? = null +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header con icono y título + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + + // Barra de progreso + if (usedSize > 0) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatFileSize(usedSize), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (maxSize != null) { + Text( + text = if (maxSize == -1L) stringResource(R.string.unlimited) + else formatFileSize(maxSize), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } else { + Text( + text = stringResource(R.string.size_used, formatFileSize(0)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Contenido extra (ListPreference) + extraContent?.invoke() + + // Botones de acción + if (usedSize > 0) { + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (onManageClick != null) { + ActionButton( + text = stringResource(R.string.manage), + icon = R.drawable.settings, + onClick = onManageClick, + modifier = Modifier.weight(1f) + ) + } + + ActionButton( + text = stringResource(R.string.clear), + icon = R.drawable.delete, + onClick = onClearClick, + modifier = Modifier.weight(1f), + isDestructive = true + ) + } + } + } + } +} + +@Composable +private fun ActionButton( + text: String, + icon: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isDestructive: Boolean = false +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background( + if (isDestructive) + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + else + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + .clickable(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 16.dp), + contentAlignment = Alignment.Center + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = if (isDestructive) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = if (isDestructive) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoilApi::class) +@Composable +private fun CachedSongsBottomSheet( + playerCache: androidx.media3.datasource.cache.Cache, + viewModel: HistoryViewModel, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + val events by viewModel.events.collectAsState() + + // Obtener IDs de canciones en caché + val cachedSongIds = remember(playerCache) { + playerCache.keys.map { it.toString() }.toSet() + } + + // Obtener canciones completas desde el historial (similar a CachePlaylistScreen) + val cachedSongs = remember(events, cachedSongIds) { + events.values.flatten() + .map { it.song } + .distinctBy { it.id } + .filter { it.id in cachedSongIds } + } + + // Obtener tamaños de caché - MEJORA: Filtrar canciones con tamaño 0 + val cachedSongsWithSize = remember(cachedSongs, playerCache) { + cachedSongs.mapNotNull { song -> + val size = tryOrNull { + playerCache.getCachedBytes(song.id, 0, Long.MAX_VALUE) + } ?: 0L + + if (size > 0) { + CachedSongInfo(song = song, size = size) + } else null + }.sortedByDescending { it.size } + } + + var displayedSongs by remember { mutableStateOf(cachedSongsWithSize) } + + LaunchedEffect(cachedSongsWithSize) { + displayedSongs = cachedSongsWithSize + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + dragHandle = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .width(32.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + ) + } + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = stringResource(R.string.cached_playlist), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = pluralStringResource( + R.plurals.n_song, + displayedSongs.size, + displayedSongs.size + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (displayedSongs.isNotEmpty()) { + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.IO) { + try { + // MEJORA: Conversión a lista antes de iterar + playerCache.keys.toList().forEach { key -> + tryOrNull { playerCache.removeResource(key) } + } + // MEJORA: Actualización del UI con withContext + withContext(Dispatchers.Main) { + displayedSongs = emptyList() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + }, + onLongClick = { /* No action on long click */ } + ) { + Icon( + painter = painterResource(R.drawable.delete), + contentDescription = stringResource(R.string.clear_all_downloads), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + + // Lista de canciones + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(displayedSongs, key = { it.song.id }) { songInfo -> + CachedSongItem( + songInfo = songInfo, + onDeleteClick = { + coroutineScope.launch(Dispatchers.IO) { + try { + // MEJORA: Búsqueda correcta de keys por canción + val keysToRemove = playerCache.keys.filter { key -> + key.contains(songInfo.song.id) + } + + keysToRemove.forEach { key -> + tryOrNull { playerCache.removeResource(key) } + } + + // MEJORA: Actualización del UI con withContext + withContext(Dispatchers.Main) { + displayedSongs = displayedSongs.filter { + it.song.id != songInfo.song.id + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + ) + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } +} + +@Composable +private fun CachedSongItem( + songInfo: CachedSongInfo, + onDeleteClick: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Thumbnail + AsyncImage( + model = songInfo.song.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + // Info + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = songInfo.song.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + Text( + text = songInfo.song.artists.joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + Text( + text = formatFileSize(songInfo.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + + // Delete button + IconButton( + onClick = onDeleteClick, + onLongClick = { /* No action on long click */ } + ) { + Icon( + painter = painterResource(R.drawable.delete), + contentDescription = stringResource(R.string.clear_song_cache), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +private data class CachedSongInfo( + val song: Song, + val size: Long +) \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/theme/Theme.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/theme/Theme.kt new file mode 100644 index 00000000..214455f8 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/theme/Theme.kt @@ -0,0 +1,202 @@ +package com.darkxvenom.airbeats.ui.theme + +import android.graphics.Bitmap +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.palette.graphics.Palette +import com.darkxvenom.airbeats.constants.PlayerBackgroundStyle +import com.google.material.color.dynamiccolor.DynamicScheme +import com.google.material.color.hct.Hct +import com.google.material.color.scheme.SchemeTonalSpot +import com.google.material.color.score.Score + +val DefaultThemeColor = Color(0xFF4285F4) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AirBeatsTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + pureBlack: Boolean = false, + expressive: Boolean = true, + themeColor: Color = DefaultThemeColor, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + + val colorScheme = remember(darkTheme, pureBlack, themeColor) { + if (themeColor == DefaultThemeColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (darkTheme) { + dynamicDarkColorScheme(context).pureBlack(pureBlack, darkTheme) + } else { + dynamicLightColorScheme(context).pureBlack(false, darkTheme) + } + } else { + SchemeTonalSpot(Hct.fromInt(themeColor.toArgb()), darkTheme, 0.0) + .toColorScheme() + .pureBlack(pureBlack, darkTheme) + } + } + + val motionScheme = if (expressive) { + MotionScheme.expressive() + } else { + MotionScheme.standard() + } + + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes, + motionScheme = motionScheme, + content = content + ) +} + +fun Bitmap.extractThemeColor(): Color { + val colorsToPopulation = Palette.from(this) + .maximumColorCount(8) + .generate() + .swatches + .associate { it.rgb to it.population } + val rankedColors = Score.score(colorsToPopulation) + return Color(rankedColors.first()) +} + +fun Bitmap.extractGradientColors(): List { + val extractedColors = Palette.from(this) + .maximumColorCount(64) + .generate() + .swatches + .associate { it.rgb to it.population } + + val orderedColors = Score.score(extractedColors, 2, 0xFF4285F4.toInt(), true) + .sortedByDescending { Color(it).luminance() } + + return if (orderedColors.size >= 2) + listOf(Color(orderedColors[0]), Color(orderedColors[1])) + else + listOf(Color(0xFF595959), Color(0xFF0D0D0D)) +} + +object PlayerColorExtractor { + fun extractGradientColors( + palette: Palette, + fallbackColor: Int = Color(0xFF595959).toArgb() + ): List { + val extractedColors = palette.swatches + .associate { it.rgb to it.population } + + val orderedColors = Score.score(extractedColors, 2, fallbackColor, true) + .sortedByDescending { Color(it).luminance() } + + return if (orderedColors.size >= 2) { + listOf(Color(orderedColors[0]), Color(orderedColors[1])) + } else { + listOf(Color(0xFF595959), Color(0xFF0D0D0D)) + } + } +} + +fun DynamicScheme.toColorScheme() = + ColorScheme( + primary = Color(primary), + onPrimary = Color(onPrimary), + primaryContainer = Color(primaryContainer), + onPrimaryContainer = Color(onPrimaryContainer), + inversePrimary = Color(inversePrimary), + secondary = Color(secondary), + onSecondary = Color(onSecondary), + secondaryContainer = Color(secondaryContainer), + onSecondaryContainer = Color(onSecondaryContainer), + tertiary = Color(tertiary), + onTertiary = Color(onTertiary), + tertiaryContainer = Color(tertiaryContainer), + onTertiaryContainer = Color(onTertiaryContainer), + background = Color(background), + onBackground = Color(onBackground), + surface = Color(surface), + onSurface = Color(onSurface), + surfaceVariant = Color(surfaceVariant), + onSurfaceVariant = Color(onSurfaceVariant), + surfaceTint = Color(primary), + inverseSurface = Color(inverseSurface), + inverseOnSurface = Color(inverseOnSurface), + error = Color(error), + onError = Color(onError), + errorContainer = Color(errorContainer), + onErrorContainer = Color(onErrorContainer), + outline = Color(outline), + outlineVariant = Color(outlineVariant), + scrim = Color(scrim), + surfaceBright = Color(surfaceBright), + surfaceDim = Color(surfaceDim), + surfaceContainer = Color(surfaceContainer), + surfaceContainerHigh = Color(surfaceContainerHigh), + surfaceContainerHighest = Color(surfaceContainerHighest), + surfaceContainerLow = Color(surfaceContainerLow), + surfaceContainerLowest = Color(surfaceContainerLowest), + ) + +fun ColorScheme.pureBlack(apply: Boolean, isDarkTheme: Boolean) = + if (apply && isDarkTheme) { + copy( + surface = Color.Black, + background = Color.Black, + surfaceContainer = Color.Black, + surfaceContainerLow = Color.Black, + surfaceContainerLowest = Color.Black, + ) + } else { + this + } + +val ColorSaver = object : Saver { + override fun restore(value: Int): Color = Color(value) + override fun SaverScope.save(value: Color): Int = value.toArgb() +} + +object PlayerSliderColors { + @Composable + fun getSliderColors( + textButtonColor: Color, + playerBackground: PlayerBackgroundStyle, + useDarkTheme: Boolean + ) = SliderDefaults.colors( + activeTrackColor = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> textButtonColor + PlayerBackgroundStyle.BLUR -> Color.White + PlayerBackgroundStyle.GRADIENT -> Color.White + }, + inactiveTrackColor = when { + useDarkTheme -> Color.Gray.copy(alpha = 0.5f) + else -> Color.Gray.copy(alpha = 0.3f) + }, + activeTickColor = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> textButtonColor + PlayerBackgroundStyle.BLUR -> Color.White + PlayerBackgroundStyle.GRADIENT -> Color.White + }, + inactiveTickColor = Color.Gray, + thumbColor = when (playerBackground) { + PlayerBackgroundStyle.DEFAULT -> textButtonColor + PlayerBackgroundStyle.BLUR -> Color.White + PlayerBackgroundStyle.GRADIENT -> Color.White + } + ) +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/utils/AppBar.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/AppBar.kt new file mode 100644 index 00000000..fc203b5e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/AppBar.kt @@ -0,0 +1,73 @@ +package com.darkxvenom.airbeats.ui.utils + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.TopAppBarState +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun appBarScrollBehavior( + state: TopAppBarState = rememberTopAppBarState(), + canScroll: () -> Boolean = { true }, + snapAnimationSpec: AnimationSpec? = spring(stiffness = Spring.StiffnessMediumLow), + flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay(), +): TopAppBarScrollBehavior = + AppBarScrollBehavior( + state = state, + snapAnimationSpec = snapAnimationSpec, + flingAnimationSpec = flingAnimationSpec, + canScroll = canScroll, + ) + +@ExperimentalMaterial3Api +class AppBarScrollBehavior( + override val state: TopAppBarState, + override val snapAnimationSpec: AnimationSpec?, + override val flingAnimationSpec: DecayAnimationSpec?, + val canScroll: () -> Boolean = { true }, +) : TopAppBarScrollBehavior { + override val isPinned: Boolean = true + override var nestedScrollConnection = + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!canScroll()) return Offset.Zero + state.contentOffset += consumed.y + if (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) { + if (consumed.y == 0f && available.y > 0f) { + // Reset the total content offset to zero when scrolling all the way down. + // This will eliminate some float precision inaccuracies. + state.contentOffset = 0f + } + } + state.heightOffset += consumed.y + return Offset.Zero + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +suspend fun TopAppBarState.resetHeightOffset() { + if (heightOffset != 0f) { + animate( + initialValue = heightOffset, + targetValue = 0f, + ) { value, _ -> + heightOffset = value + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/utils/FadingEdge.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/FadingEdge.kt new file mode 100644 index 00000000..8c8eb143 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/FadingEdge.kt @@ -0,0 +1,89 @@ +package com.darkxvenom.airbeats.ui.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 +import androidx.compose.ui.unit.Dp + +fun Modifier.fadingEdge( + left: Dp? = null, + top: Dp? = null, + right: Dp? = null, + bottom: Dp? = null, +) = graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + if (top != null) { + drawRect( + brush = + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, + Color.Black, + ), + startY = 0f, + endY = top.toPx(), + ), + blendMode = BlendMode.DstIn, + ) + } + if (bottom != null) { + drawRect( + brush = + Brush.verticalGradient( + colors = + listOf( + Color.Black, + Color.Transparent, + ), + startY = size.height - bottom.toPx(), + endY = size.height, + ), + blendMode = BlendMode.DstIn, + ) + } + if (left != null) { + drawRect( + brush = + Brush.horizontalGradient( + colors = + listOf( + Color.Black, + Color.Transparent, + ), + startX = 0f, + endX = left.toPx(), + ), + blendMode = BlendMode.DstIn, + ) + } + if (right != null) { + drawRect( + brush = + Brush.horizontalGradient( + colors = + listOf( + Color.Transparent, + Color.Black, + ), + startX = size.width - right.toPx(), + endX = size.width, + ), + blendMode = BlendMode.DstIn, + ) + } + } + +fun Modifier.fadingEdge( + horizontal: Dp? = null, + vertical: Dp? = null, +) = fadingEdge( + left = horizontal, + right = horizontal, + top = vertical, + bottom = vertical, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/utils/ItemWrapper.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/ItemWrapper.kt new file mode 100644 index 00000000..cc2a7a4a --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/ItemWrapper.kt @@ -0,0 +1,15 @@ +package com.darkxvenom.airbeats.ui.utils + +import androidx.compose.runtime.mutableStateOf + +class ItemWrapper( + val item: T, +) { + private val _isSelected = mutableStateOf(true) + + var isSelected: Boolean + get() = _isSelected.value + set(value) { + _isSelected.value = value + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/utils/LazyGridSnapLayoutInfoProvider.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/LazyGridSnapLayoutInfoProvider.kt new file mode 100644 index 00000000..b8b38f2f --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/LazyGridSnapLayoutInfoProvider.kt @@ -0,0 +1,70 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.darkxvenom.airbeats.ui.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.util.fastForEach + +@ExperimentalFoundationApi +fun SnapLayoutInfoProvider( + lazyGridState: LazyGridState, + positionInLayout: (layoutSize: Float, itemSize: Float) -> Float = { layoutSize, itemSize -> + (layoutSize / 2f - itemSize / 2f) + }, +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { + private val layoutInfo: LazyGridLayoutInfo + get() = lazyGridState.layoutInfo + + override fun calculateApproachOffset(velocity: Float, decayOffset: Float): Float = 0f + override fun calculateSnapOffset(velocity: Float): Float { + val bounds = calculateSnappingOffsetBounds() + return when { + velocity < 0 -> bounds.start + velocity > 0 -> bounds.endInclusive + else -> 0f + } + } + + fun 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) + } +} + +fun calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyGridLayoutInfo, + item: LazyGridItemInfo, + positionInLayout: (layoutSize: Float, itemSize: Float) -> Float, +): Float { + val containerSize = + layoutInfo.singleAxisViewportSize - layoutInfo.beforeContentPadding - layoutInfo.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 diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/utils/NavControllerUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/NavControllerUtils.kt new file mode 100644 index 00000000..448ee55c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/NavControllerUtils.kt @@ -0,0 +1,14 @@ +package com.darkxvenom.airbeats.ui.utils + +import androidx.compose.ui.util.fastAny +import androidx.navigation.NavController +import com.darkxvenom.airbeats.ui.screens.Screens + +val NavController.canNavigateUp: Boolean + get() = currentBackStackEntry?.destination?.parent?.route != null + +fun NavController.backToMain() { + while (canNavigateUp && !Screens.MainScreens.fastAny { it.route == currentBackStackEntry?.destination?.route }) { + navigateUp() + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/utils/ScrollUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/ScrollUtils.kt new file mode 100644 index 00000000..83609040 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/ScrollUtils.kt @@ -0,0 +1,59 @@ +package com.darkxvenom.airbeats.ui.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 + +@Composable +fun LazyListState.isScrollingUp(): Boolean { + var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) } + return remember(this) { + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + }.also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } + } + }.value +} + +@Composable +fun LazyGridState.isScrollingUp(): Boolean { + var previousIndex by remember(this) { mutableStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableStateOf(firstVisibleItemScrollOffset) } + return remember(this) { + derivedStateOf { + if (previousIndex != firstVisibleItemIndex) { + previousIndex > firstVisibleItemIndex + } else { + previousScrollOffset >= firstVisibleItemScrollOffset + }.also { + previousIndex = firstVisibleItemIndex + previousScrollOffset = firstVisibleItemScrollOffset + } + } + }.value +} + +@Composable +fun ScrollState.isScrollingUp(): Boolean { + var previousScrollOffset by remember(this) { mutableStateOf(value) } + return remember(this) { + derivedStateOf { + (previousScrollOffset >= value).also { + previousScrollOffset = value + } + } + }.value +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/utils/ShapeUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/ShapeUtils.kt new file mode 100644 index 00000000..7496fff7 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/ShapeUtils.kt @@ -0,0 +1,8 @@ +package com.darkxvenom.airbeats.ui.utils + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.ui.unit.dp + +fun CornerBasedShape.top(): CornerBasedShape = + copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)) diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/utils/StringUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/StringUtils.kt new file mode 100644 index 00000000..75f2c387 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/StringUtils.kt @@ -0,0 +1,30 @@ +package com.darkxvenom.airbeats.ui.utils + +import kotlin.math.absoluteValue + +fun formatFileSize(sizeBytes: Long): String { + val prefix = if (sizeBytes < 0) "-" else "" + var result: Long = sizeBytes.absoluteValue + var suffix = "B" + if (result > 900) { + suffix = "KB" + result /= 1024 + } + if (result > 900) { + suffix = "MB" + result /= 1024 + } + if (result > 900) { + suffix = "GB" + result /= 1024 + } + if (result > 900) { + suffix = "TB" + result /= 1024 + } + if (result > 900) { + suffix = "PB" + result /= 1024 + } + return "$prefix$result $suffix" +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/ui/utils/YouTubeUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/YouTubeUtils.kt new file mode 100644 index 00000000..0ffa2876 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/ui/utils/YouTubeUtils.kt @@ -0,0 +1,21 @@ +package com.darkxvenom.airbeats.ui.utils + +fun String.resize( + width: Int? = null, + height: Int? = null, +): String { + if (width == null && height == null) return this + "https://lh3\\.googleusercontent\\.com/.*=w(\\d+)-h(\\d+).*".toRegex() + .matchEntire(this)?.groupValues?.let { group -> + val (W, H) = group.drop(1).map { it.toInt() } + var w = width + var h = height + if (w != null && h == null) h = (w / W) * H + if (w == null && h != null) w = (h / H) * W + return "${split("=w")[0]}=w$w-h$h-p-l90-rj" + } + if (this matches "https://yt3\\.ggpht\\.com/.*=s(\\d+)".toRegex()) { + return "$this-s${width ?: height}" + } + return this +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/CoilBitmapLoader.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/CoilBitmapLoader.kt new file mode 100644 index 00000000..ed6272e1 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/CoilBitmapLoader.kt @@ -0,0 +1,49 @@ +package com.darkxvenom.airbeats.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import androidx.media3.common.util.BitmapLoader +import coil.imageLoader +import coil.request.ErrorResult +import coil.request.ImageRequest +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.guava.future +import java.util.concurrent.ExecutionException + +class CoilBitmapLoader( + private val context: Context, + private val scope: CoroutineScope, +) : BitmapLoader { + override fun supportsMimeType(mimeType: String): Boolean = mimeType.startsWith("image/") + + override fun decodeBitmap(data: ByteArray): ListenableFuture = + scope.future(Dispatchers.IO) { + BitmapFactory.decodeByteArray(data, 0, data.size) + ?: error("Could not decode image data") + } + + override fun loadBitmap(uri: Uri): ListenableFuture = + scope.future(Dispatchers.IO) { + val result = + context.imageLoader.execute( + ImageRequest + .Builder(context) + .data(uri) + .allowHardware(false) + .build(), + ) + if (result is ErrorResult) { + throw ExecutionException(result.throwable) + } + try { + (result.drawable as BitmapDrawable).bitmap + } catch (e: Exception) { + throw ExecutionException(e) + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/ComposeDebugUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/ComposeDebugUtils.kt new file mode 100644 index 00000000..746e49b2 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/ComposeDebugUtils.kt @@ -0,0 +1,100 @@ +package com.darkxvenom.airbeats.utils + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlin.math.min + +/** + * A [Modifier] that draws a border around elements that are recomposing. + * The border increases in size and interpolates from red to green as more + * recompositions occur before a timeout. + */ +@Stable +fun Modifier.recomposeHighlighter(): Modifier = this.then(recomposeModifier) + +// Use a single instance + @Stable to ensure that recompositions can enable skipping optimizations +// Modifier.composed will still remember unique data per call site. +private val recomposeModifier = + Modifier.composed(inspectorInfo = debugInspectorInfo { name = "recomposeHighlighter" }) { + // The total number of compositions that have occurred. We're not using a State<> here be + // able to read/write the value without invalidating (which would cause infinite + // recomposition). + val totalCompositions = remember { arrayOf(0L) } + totalCompositions[0]++ + + // The value of totalCompositions at the last timeout. + val totalCompositionsAtLastTimeout = remember { mutableStateOf(0L) } + + // Start the timeout, and reset everytime there's a recomposition. (Using totalCompositions + // as the key is really just to cause the timer to restart every composition). + LaunchedEffect(totalCompositions[0]) { + delay(3000) + totalCompositionsAtLastTimeout.value = totalCompositions[0] + } + + Modifier.drawWithCache { + onDrawWithContent { + // Draw actual content. + drawContent() + + // Below is to draw the highlight, if necessary. A lot of the logic is copied from + // Modifier.border + val numCompositionsSinceTimeout = + totalCompositions[0] - totalCompositionsAtLastTimeout.value + + val hasValidBorderParams = size.minDimension > 0f + if (!hasValidBorderParams || numCompositionsSinceTimeout <= 0) { + return@onDrawWithContent + } + + val (color, strokeWidthPx) = + when (numCompositionsSinceTimeout) { + // We need at least one composition to draw, so draw the smallest border + // color in blue. + 1L -> Color.Blue to 1f + // 2 compositions is _probably_ okay. + 2L -> Color.Green to 2.dp.toPx() + // 3 or more compositions before timeout may indicate an issue. lerp the + // color from yellow to red, and continually increase the border size. + else -> { + lerp( + Color.Yellow.copy(alpha = 0.8f), + Color.Red.copy(alpha = 0.5f), + min(1f, (numCompositionsSinceTimeout - 1).toFloat() / 100f), + ) to numCompositionsSinceTimeout.toInt().dp.toPx() + } + } + + val halfStroke = strokeWidthPx / 2 + val topLeft = Offset(halfStroke, halfStroke) + val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx) + + val fillArea = (strokeWidthPx * 2) > size.minDimension + val rectTopLeft = if (fillArea) Offset.Zero else topLeft + val size = if (fillArea) size else borderSize + val style = if (fillArea) Fill else Stroke(strokeWidthPx) + + drawRect( + brush = SolidColor(color), + topLeft = rectTopLeft, + size = size, + style = style, + ) + } + } + } diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/DataStore.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/DataStore.kt new file mode 100644 index 00000000..ab3f9899 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/DataStore.kt @@ -0,0 +1,117 @@ +package com.darkxvenom.airbeats.utils + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.darkxvenom.airbeats.extensions.toEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.properties.ReadOnlyProperty + +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +operator fun DataStore.get(key: Preferences.Key): T? = + runBlocking(Dispatchers.IO) { + data.first()[key] + } + +fun DataStore.get( + key: Preferences.Key, + defaultValue: T, +): T = + runBlocking(Dispatchers.IO) { + data.first()[key] ?: defaultValue + } + +fun preference( + context: Context, + key: Preferences.Key, + defaultValue: T, +) = ReadOnlyProperty { _, _ -> context.dataStore[key] ?: defaultValue } + +inline fun > enumPreference( + context: Context, + key: Preferences.Key, + defaultValue: T, +) = ReadOnlyProperty { _, _ -> context.dataStore[key].toEnum(defaultValue) } + +@Composable +fun rememberPreference( + key: Preferences.Key, + defaultValue: T, +): MutableState { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val state = + remember { + context.dataStore.data + .map { it[key] ?: defaultValue } + .distinctUntilChanged() + }.collectAsState(context.dataStore[key] ?: defaultValue) + + return remember { + object : MutableState { + override var value: T + get() = state.value + set(value) { + coroutineScope.launch { + context.dataStore.edit { + it[key] = value + } + } + } + + override fun component1() = value + + override fun component2(): (T) -> Unit = { value = it } + } + } +} + +@Composable +inline fun > rememberEnumPreference( + key: Preferences.Key, + defaultValue: T, +): MutableState { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val initialValue = context.dataStore[key].toEnum(defaultValue = defaultValue) + val state = + remember { + context.dataStore.data + .map { it[key].toEnum(defaultValue = defaultValue) } + .distinctUntilChanged() + }.collectAsState(initialValue) + + return remember { + object : MutableState { + override var value: T + get() = state.value + set(value) { + coroutineScope.launch { + context.dataStore.edit { + it[key] = value.name + } + } + } + + override fun component1() = value + + override fun component2(): (T) -> Unit = { value = it } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/DiscordRPC.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/DiscordRPC.kt new file mode 100644 index 00000000..ee16151d --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/DiscordRPC.kt @@ -0,0 +1,53 @@ +package com.darkxvenom.airbeats.utils + +import android.content.Context +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.entities.Song +import com.my.kizzy.rpc.KizzyRPC +import com.my.kizzy.rpc.RpcImage + +class DiscordRPC( + val context: Context, + token: String, +) : KizzyRPC(token) { + suspend fun updateSong(song: Song, currentPlaybackTimeMillis: Long, playbackSpeed: Float = 1.0f, useDetails: Boolean = false) = runCatching { + val currentTime = System.currentTimeMillis() + + val adjustedPlaybackTime = (currentPlaybackTimeMillis / playbackSpeed).toLong() + val calculatedStartTime = currentTime - adjustedPlaybackTime + + val songTitleWithRate = if (playbackSpeed != 1.0f) { + "${song.song.title} [${String.format("%.2fx", playbackSpeed)}]" + } else { + song.song.title + } + + val remainingDuration = song.song.duration * 1000L - currentPlaybackTimeMillis + val adjustedRemainingDuration = (remainingDuration / playbackSpeed).toLong() + + setActivity( + name = context.getString(R.string.app_name).removeSuffix(" Debug"), + details = songTitleWithRate, + state = song.artists.joinToString { it.name }, + detailsUrl = "https://music.youtube.com/watch?v=${song.song.id}", + largeImage = song.song.thumbnailUrl?.let { RpcImage.ExternalImage(it) }, + smallImage = song.artists.firstOrNull()?.thumbnailUrl?.let { RpcImage.ExternalImage(it) }, + largeText = song.album?.title, + smallText = song.artists.firstOrNull()?.name, + buttons = listOf( + "Listen on YouTube Music" to "https://music.youtube.com/watch?v=${song.song.id}", + "Visit AirBeats" to "https://github.com/darkxvenom/airbeats" + ), + type = Type.LISTENING, + statusDisplayType = if (useDetails) StatusDisplayType.DETAILS else StatusDisplayType.STATE, + since = currentTime, + startTime = calculatedStartTime, + endTime = currentTime + adjustedRemainingDuration, + applicationId = APPLICATION_ID + ) + } + + companion object { + private const val APPLICATION_ID = "1411019391843172514" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/LyricsImageGenerator.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/LyricsImageGenerator.kt new file mode 100644 index 00000000..063bc0f8 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/LyricsImageGenerator.kt @@ -0,0 +1,630 @@ +package com.darkxvenom.airbeats.utils + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.DashPathEffect +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.Typeface +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import androidx.annotation.RequiresApi +import androidx.core.content.FileProvider +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.withTranslation +import coil.ImageLoader +import coil.request.ImageRequest +import com.darkxvenom.airbeats.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream + +object ComposeToImage { + + fun saveBitmapAsFile(context: Context, bitmap: Bitmap, fileName: String): Uri { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveToMediaStore(context, bitmap, fileName) + } else { + saveToCache(context, bitmap, fileName) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun saveToMediaStore(context: Context, bitmap: Bitmap, fileName: String): Uri { + val contentValues = buildContentValues(fileName) + val uri = context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ) ?: throw IllegalStateException("Failed to create new MediaStore record") + + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + } + return uri + } + + private fun buildContentValues(fileName: String): ContentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, "$fileName.png") + put(MediaStore.MediaColumns.MIME_TYPE, "image/png") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/AirBeats") + } + + private fun saveToCache(context: Context, bitmap: Bitmap, fileName: String): Uri { + val cachePath = File(context.cacheDir, "images") + cachePath.mkdirs() + val imageFile = File(cachePath, "$fileName.png") + + FileOutputStream(imageFile).use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + } + + return FileProvider.getUriForFile( + context, + "${context.packageName}.FileProvider", + imageFile + ) + } + + @RequiresApi(Build.VERSION_CODES.M) + suspend fun createLyricsImage( + context: Context, + coverArtUrl: String?, + songTitle: String, + artistName: String, + lyrics: String, + width: Int, + height: Int, + backgroundColor: Int? = null, + textColor: Int? = null, + secondaryTextColor: Int? = null, + showCoverArt: Boolean = true, + showLogo: Boolean = true, + backgroundStyle: String = "SOLID", + gradientColors: IntArray? = null, + fontStyle: String = "EXTRA_BOLD", + logoPosition: String = "BOTTOM_LEFT", + cornerRadius: Float = 28f, + patternOpacity: Float = 0.03f, + textAlignment: String = "CENTER", + padding: Float = 32f, + showArtistName: Boolean = true, + showSongTitle: Boolean = true, + textShadowEnabled: Boolean = true, + borderEnabled: Boolean = false, + borderColor: Int? = null, + borderWidth: Float = 2f, + logoSize: String = "MEDIUM", + coverArtStyle: String = "ROUNDED", + lyricsStyle: String = "NORMAL", + showAccentLine: Boolean = false, + accentColor: Int? = null, + spacingBetweenElements: Float = 16f, + lyricsLineSpacing: Float = 1.3f + ): Bitmap = withContext(Dispatchers.Default) { + val imageSize = 1080 + val bitmap = createBitmap(imageSize, imageSize) + val canvas = Canvas(bitmap) + + val bgColor = backgroundColor ?: 0xFF0A0A0A.toInt() + val mainTextColor = textColor ?: 0xFFFFFFFF.toInt() + val secTextColor = secondaryTextColor ?: 0xFFB0B0B0.toInt() + val bColor = borderColor ?: mainTextColor + val aColor = accentColor ?: mainTextColor + + val outerPadding = imageSize * (padding / 380f) + val thumbnailSize = imageSize * 0.21f + val logoSizeMultiplier = when (logoSize) { + "SMALL" -> 0.7f + "MEDIUM" -> 1f + "LARGE" -> 1.3f + else -> 1f + } + val logoSizePx = imageSize * 0.073f * logoSizeMultiplier + val cornerRadiusPx = (cornerRadius / 28f) * (imageSize * 0.073f) + val elementSpacing = imageSize * (spacingBetweenElements / 380f) + + val bgRect = RectF(0f, 0f, imageSize.toFloat(), imageSize.toFloat()) + renderBackground( + canvas, + bgRect, + cornerRadiusPx, + bgColor, + mainTextColor, + backgroundStyle, + gradientColors, + patternOpacity, + imageSize + ) + + if (borderEnabled) { + val borderPaint = Paint().apply { + color = bColor + style = Paint.Style.STROKE + strokeWidth = borderWidth * (imageSize / 380f) + isAntiAlias = true + } + canvas.drawRoundRect(bgRect, cornerRadiusPx, cornerRadiusPx, borderPaint) + } + + if (showCoverArt) { + val coverArt = loadCoverArtwork(context, coverArtUrl) + renderCoverArtHighRes( + canvas, + coverArt, + outerPadding, + thumbnailSize, + mainTextColor, + coverArtStyle + ) + + val metadataHeight = renderSongMetadataHighRes( + canvas, + songTitle, + artistName, + outerPadding, + thumbnailSize, + imageSize, + mainTextColor, + secTextColor, + fontStyle, + showSongTitle, + showArtistName, + elementSpacing + ) + + if (showAccentLine) { + renderAccentLine( + canvas, + outerPadding, + outerPadding + thumbnailSize + elementSpacing + metadataHeight + elementSpacing, + imageSize, + aColor + ) + } + } + + renderLyricsHighRes( + canvas, + lyrics, + imageSize, + outerPadding, + thumbnailSize, + mainTextColor, + bgColor, + fontStyle, + showCoverArt, + textAlignment, + textShadowEnabled, + lyricsStyle, + elementSpacing, + showAccentLine, + lyricsLineSpacing + ) + + if (showLogo && logoPosition != "NONE") { + renderAppLogoHighRes( + context, + canvas, + imageSize, + outerPadding, + logoSizePx, + mainTextColor, + logoPosition, + logoSizeMultiplier + ) + } + + bitmap + } + + private fun renderBackground( + canvas: Canvas, + rect: RectF, + cornerRadius: Float, + backgroundColor: Int, + textColor: Int, + backgroundStyle: String, + gradientColors: IntArray?, + patternOpacity: Float, + imageSize: Int + ) { + when (backgroundStyle) { + "SOLID" -> { + val paint = Paint().apply { + color = backgroundColor + isAntiAlias = true + } + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + } + "GRADIENT" -> { + val colors = gradientColors ?: intArrayOf(backgroundColor, backgroundColor) + val paint = Paint().apply { + isAntiAlias = true + shader = LinearGradient( + 0f, 0f, + rect.width(), rect.height(), + colors, + null, + Shader.TileMode.CLAMP + ) + } + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + } + "PATTERN" -> { + val paint = Paint().apply { + color = backgroundColor + isAntiAlias = true + } + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + + val clipPath = Path().apply { + addRoundRect(rect, cornerRadius, cornerRadius, Path.Direction.CW) + } + canvas.save() + canvas.clipPath(clipPath) + + val patternPaint = Paint().apply { + color = textColor + alpha = (patternOpacity * 255).toInt() + isAntiAlias = true + } + val pattern = imageSize * 0.037f + val radius = imageSize * 0.00185f + var x = 0f + while (x <= rect.width()) { + var y = 0f + while (y <= rect.height()) { + canvas.drawCircle(x, y, radius, patternPaint) + y += pattern + } + x += pattern + } + canvas.restore() + } + } + } + + private suspend fun loadCoverArtwork(context: Context, coverArtUrl: String?): Bitmap? { + if (coverArtUrl.isNullOrEmpty()) return null + return try { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(coverArtUrl) + .size(800, 800) + .allowHardware(false) + .build() + val result = imageLoader.execute(request) + result.drawable?.toBitmap(800, 800, Bitmap.Config.ARGB_8888) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + private fun renderCoverArtHighRes( + canvas: Canvas, + coverArt: Bitmap?, + padding: Float, + size: Float, + textColor: Int, + coverArtStyle: String + ) { + coverArt?.let { artwork -> + val rect = RectF(padding, padding, padding + size, padding + size) + + val cornerRadius = when (coverArtStyle) { + "CIRCLE" -> size / 2f + "SQUARE" -> size * 0.05f + "ROUNDED" -> size * 0.25f + else -> size * 0.25f + } + + val bgPaint = Paint().apply { + color = textColor + alpha = 25 + isAntiAlias = true + } + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint) + + val paint = Paint().apply { + isAntiAlias = true + isFilterBitmap = true + } + val clipPath = Path().apply { + addRoundRect(rect, cornerRadius, cornerRadius, Path.Direction.CW) + } + canvas.save() + canvas.clipPath(clipPath) + canvas.drawBitmap(artwork, null, rect, paint) + canvas.restore() + + val borderPaint = Paint().apply { + color = textColor + alpha = 51 + style = Paint.Style.STROKE + strokeWidth = 2f + isAntiAlias = true + } + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, borderPaint) + } + } + + private fun renderSongMetadataHighRes( + canvas: Canvas, + songTitle: String, + artistName: String, + padding: Float, + thumbnailSize: Float, + imageSize: Int, + mainTextColor: Int, + secondaryTextColor: Int, + fontStyle: String, + showSongTitle: Boolean, + showArtistName: Boolean, + elementSpacing: Float + ): Float { + val startX = padding + thumbnailSize + elementSpacing + val maxWidth = (imageSize - startX - padding).toInt() + + val titleTypeface = when (fontStyle) { + "REGULAR" -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + "BOLD" -> Typeface.create("sans-serif-black", Typeface.NORMAL) + "EXTRA_BOLD" -> Typeface.create("sans-serif-black", Typeface.BOLD) + else -> Typeface.create("sans-serif-black", Typeface.BOLD) + } + + val titlePaint = TextPaint().apply { + color = mainTextColor + textSize = imageSize * 0.0526f + typeface = titleTypeface + isAntiAlias = true + letterSpacing = -0.025f + } + + val artistPaint = TextPaint().apply { + color = secondaryTextColor + textSize = imageSize * 0.042f + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + isAntiAlias = true + letterSpacing = 0.0125f + } + + val titleLayout = if (showSongTitle) { + StaticLayout.Builder.obtain( + songTitle, 0, songTitle.length, titlePaint, maxWidth + ) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setMaxLines(2) + .setLineSpacing(0f, 1.2f) + .setEllipsize(android.text.TextUtils.TruncateAt.END) + .build() + } else null + + val artistLayout = if (showArtistName) { + StaticLayout.Builder.obtain( + artistName, 0, artistName.length, artistPaint, maxWidth + ) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setMaxLines(1) + .setEllipsize(android.text.TextUtils.TruncateAt.END) + .build() + } else null + + val centerY = padding + thumbnailSize / 2f + val titleHeight = titleLayout?.height ?: 0 + val artistHeight = artistLayout?.height ?: 0 + val spacing2 = if (showSongTitle && showArtistName) imageSize * 0.0105f else 0f + val textBlockHeight = titleHeight + artistHeight + spacing2 + val startY = centerY - textBlockHeight / 2f + + canvas.withTranslation(startX, startY) { + titleLayout?.draw(this) + if (showArtistName && artistLayout != null) { + translate(0f, titleHeight + spacing2) + artistLayout.draw(this) + } + } + + return textBlockHeight + } + + private fun renderAccentLine( + canvas: Canvas, + padding: Float, + yPosition: Float, + imageSize: Int, + accentColor: Int + ) { + val linePaint = Paint().apply { + color = accentColor + strokeWidth = imageSize * 0.0028f + isAntiAlias = true + style = Paint.Style.FILL + } + + val lineRect = RectF( + padding, + yPosition, + imageSize - padding, + yPosition + imageSize * 0.0028f + ) + + val cornerRadius = imageSize * 0.0019f + canvas.drawRoundRect(lineRect, cornerRadius, cornerRadius, linePaint) + } + + private fun renderLyricsHighRes( + canvas: Canvas, + lyrics: String, + imageSize: Int, + padding: Float, + thumbnailSize: Float, + textColor: Int, + backgroundColor: Int, + fontStyle: String, + showCoverArt: Boolean, + textAlignment: String, + textShadowEnabled: Boolean, + lyricsStyle: String, + elementSpacing: Float, + showAccentLine: Boolean, + lineSpacing: Float + ) { + val lyricsTypeface = when (fontStyle) { + "REGULAR" -> Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + "BOLD" -> Typeface.create("sans-serif-black", Typeface.NORMAL) + "EXTRA_BOLD" -> Typeface.create("sans-serif-black", Typeface.BOLD) + else -> Typeface.create("sans-serif-black", Typeface.BOLD) + } + + val alignment = when (textAlignment) { + "LEFT" -> Layout.Alignment.ALIGN_NORMAL + "CENTER" -> Layout.Alignment.ALIGN_CENTER + "RIGHT" -> Layout.Alignment.ALIGN_OPPOSITE + else -> Layout.Alignment.ALIGN_CENTER + } + + val maxWidth = (imageSize * 0.85f).toInt() + val headerHeight = if (showCoverArt) { + padding + thumbnailSize + elementSpacing + (if (showAccentLine) elementSpacing * 1.5f else 0f) + } else { + padding + } + val footerHeight = imageSize * 0.148f + val maxHeight = imageSize - headerHeight - footerHeight + + var textSize = imageSize * 0.055f + val minTextSize = imageSize * 0.037f + + val paint = TextPaint().apply { + color = textColor + typeface = lyricsTypeface + isAntiAlias = true + letterSpacing = if (lyricsStyle == "CONDENSED") -0.025f else 0.015f + + if (lyricsStyle == "ITALIC") { + textSkewX = -0.25f + } + } + + if (textShadowEnabled) { + paint.setShadowLayer( + imageSize * 0.0037f, + imageSize * 0.00185f, + imageSize * 0.00185f, + backgroundColor + ) + } + + var layout: StaticLayout + do { + paint.textSize = textSize + layout = StaticLayout.Builder.obtain(lyrics, 0, lyrics.length, paint, maxWidth) + .setAlignment(alignment) + .setIncludePad(false) + .setLineSpacing(0f, lineSpacing) + .build() + if (layout.height <= maxHeight) break + textSize -= imageSize * 0.0028f + } while (textSize > minTextSize) + + if (textSize < minTextSize) { + paint.textSize = minTextSize + layout = StaticLayout.Builder.obtain(lyrics, 0, lyrics.length, paint, maxWidth) + .setAlignment(alignment) + .setIncludePad(false) + .setLineSpacing(0f, lineSpacing) + .build() + } + + val posX = (imageSize - layout.width) / 2f + val availableHeight = imageSize - headerHeight - footerHeight + val posY = headerHeight + (availableHeight - layout.height) / 2f + + canvas.withTranslation(posX, posY) { + layout.draw(this) + } + } + + private fun renderAppLogoHighRes( + context: Context, + canvas: Canvas, + imageSize: Int, + padding: Float, + logoSize: Float, + textColor: Int, + logoPosition: String, + logoSizeMultiplier: Float + ) { + val logoBitmap = getBitmapFromVectorDrawable( + context, R.drawable.airbeats, logoSize.toInt(), logoSize.toInt() + ) ?: return + + val appName = context.getString(R.string.app_name) + val textPaint = TextPaint().apply { + color = textColor + textSize = imageSize * 0.0395f * logoSizeMultiplier + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + isAntiAlias = true + letterSpacing = 0.02f + } + + val textBounds = Rect() + textPaint.getTextBounds(appName, 0, appName.length, textBounds) + val textWidth = textBounds.width() + val textHeight = textBounds.height() + + val totalWidth = logoSize + (imageSize * 0.0315f) + textWidth + val totalHeight = maxOf(logoSize, textHeight.toFloat()) + + val (logoX, logoY) = when (logoPosition) { + "BOTTOM_LEFT" -> Pair(padding, imageSize - padding - totalHeight) + "BOTTOM_RIGHT" -> Pair(imageSize - padding - totalWidth, imageSize - padding - totalHeight) + "TOP_LEFT" -> Pair(padding, padding) + "TOP_RIGHT" -> Pair(imageSize - padding - totalWidth, padding) + else -> Pair(padding, imageSize - padding - totalHeight) + } + + val paint = Paint().apply { + colorFilter = PorterDuffColorFilter(textColor, PorterDuff.Mode.SRC_IN) + isAntiAlias = true + } + + val logoCenterY = logoY + totalHeight / 2f - logoSize / 2f + canvas.drawBitmap(logoBitmap, logoX, logoCenterY, paint) + + val textX = logoX + logoSize + (imageSize * 0.0315f) + val textY = logoY + totalHeight / 2f + textHeight / 2f + + canvas.drawText(appName, textX, textY, textPaint) + } + + private fun getBitmapFromVectorDrawable( + context: Context, + drawableId: Int, + width: Int, + height: Int + ): Bitmap? { + val drawable = context.getDrawable(drawableId) ?: return null + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, width, height) + drawable.draw(canvas) + return bitmap + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/NetworkConnectivityObserver.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/NetworkConnectivityObserver.kt new file mode 100644 index 00000000..1c7a5f90 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/NetworkConnectivityObserver.kt @@ -0,0 +1,59 @@ +package com.darkxvenom.airbeats.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged + +class NetworkConnectivityObserver(context: Context) { + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val networkStatus: Flow = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + trySend(hasInternet) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + + // Send initial state + val isConnected = connectivityManager.activeNetwork?.let { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + } ?: false + trySend(isConnected) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + }.distinctUntilChanged() + + fun unregister() { + // Cleanup is handled by callbackFlow's awaitClose + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/NetworkUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/NetworkUtils.kt new file mode 100644 index 00000000..1c6d45fd --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/NetworkUtils.kt @@ -0,0 +1,20 @@ +package com.darkxvenom.airbeats.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import androidx.core.content.getSystemService + +fun isInternetAvailable(context: Context): Boolean { + val connectivityManager = context.getSystemService() ?: return false + val activeNetwork = connectivityManager.activeNetwork ?: return false + val networkCapabilities = + connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + + return when { + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + else -> false + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/PlaylistImageUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/PlaylistImageUtils.kt new file mode 100644 index 00000000..30468054 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/PlaylistImageUtils.kt @@ -0,0 +1,69 @@ +package com.darkxvenom.airbeats.utils + +import android.content.Context +import android.net.Uri +import androidx.core.content.edit +import androidx.core.net.toUri +import timber.log.Timber +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** Guarda una imagen personalizada para una playlist */ +fun saveCustomPlaylistImage(context: Context, playlistId: String, imageUri: Uri) { + try { + // Crear directorio para imágenes de playlist si no existe + val playlistImagesDir = File(context.filesDir, "playlist_images") + if (!playlistImagesDir.exists()) { + playlistImagesDir.mkdirs() + } + + // Nombre del archivo basado en el ID de la playlist (usando el ID como String) + val imageFile = File(playlistImagesDir, "playlist_${playlistId}.jpg") + + // Copiar la imagen seleccionada al directorio interno + context.contentResolver.openInputStream(imageUri)?.use { input -> + FileOutputStream(imageFile).use { output -> + input.copyTo(output) + } + } + + // Guardar la referencia en SharedPreferences + context.getSharedPreferences("playlist_images", Context.MODE_PRIVATE).edit { + putString("playlist_$playlistId", imageFile.toUri().toString()) + } + } catch (e: IOException) { + Timber.e(e, "Error al guardar imagen de playlist") + } +} + +/** Obtiene la URI de la imagen personalizada para una playlist */ +fun getPlaylistImageUri(context: Context, playlistId: String): Uri? { + val uriString = context.getSharedPreferences("playlist_images", Context.MODE_PRIVATE) + .getString("playlist_$playlistId", null) + + return if (uriString != null) { + val uri = uriString.toUri() + // Verificar que el archivo existe + val file = File(uri.path ?: "") + if (file.exists()) uri else null + } else null +} + +/** Elimina la imagen personalizada de una playlist */ +fun deletePlaylistImage(context: Context, playlistId: String) { + val uriString = context.getSharedPreferences("playlist_images", Context.MODE_PRIVATE) + .getString("playlist_$playlistId", null) + + if (uriString != null) { + val file = File(uriString.toUri().path ?: "") + if (file.exists()) { + file.delete() + } + + // Eliminar referencia de SharedPreferences + context.getSharedPreferences("playlist_images", Context.MODE_PRIVATE).edit { + remove("playlist_$playlistId") + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/ShapeUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/ShapeUtils.kt new file mode 100644 index 00000000..ff1c6276 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/ShapeUtils.kt @@ -0,0 +1,54 @@ +package com.darkxvenom.airbeats.utils + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialShapes +import androidx.graphics.shapes.RoundedPolygon + + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun getSmallButtonShape(shapeName: String): RoundedPolygon { + return when (shapeName) { + "Pill" -> MaterialShapes.Pill + "Circle" -> MaterialShapes.Circle + "Square" -> MaterialShapes.Square + "Diamond" -> MaterialShapes.Diamond + "Pentagon" -> MaterialShapes.Pentagon + "Heart" -> MaterialShapes.Heart + "Oval" -> MaterialShapes.Oval + "Arch" -> MaterialShapes.Arch + "SemiCircle" -> MaterialShapes.SemiCircle + "Triangle" -> MaterialShapes.Triangle + "Arrow" -> MaterialShapes.Arrow + "Fan" -> MaterialShapes.Fan + "Gem" -> MaterialShapes.Gem + "Bun" -> MaterialShapes.Bun + "Ghostish" -> MaterialShapes.Ghostish + "Cookie4Sided" -> MaterialShapes.Cookie4Sided + "Cookie6Sided" -> MaterialShapes.Cookie6Sided + "Cookie7Sided" -> MaterialShapes.Cookie7Sided + "Cookie9Sided" -> MaterialShapes.Cookie9Sided + "Cookie12Sided" -> MaterialShapes.Cookie12Sided + "Clover4Leaf" -> MaterialShapes.Clover4Leaf + "Clover8Leaf" -> MaterialShapes.Clover8Leaf + "Sunny" -> MaterialShapes.Sunny + "VerySunny" -> MaterialShapes.VerySunny + "Burst" -> MaterialShapes.Burst + "SoftBurst" -> MaterialShapes.SoftBurst + "Boom" -> MaterialShapes.Boom + "SoftBoom" -> MaterialShapes.SoftBoom + "Flower" -> MaterialShapes.Flower + "PixelCircle" -> MaterialShapes.PixelCircle + "PixelTriangle" -> MaterialShapes.PixelTriangle + "Puffy" -> MaterialShapes.Puffy + "PuffyDiamond" -> MaterialShapes.PuffyDiamond + "Slanted" -> MaterialShapes.Slanted + "ClamShell" -> MaterialShapes.ClamShell + else -> MaterialShapes.Circle + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun getPlayPauseShape(shapeName: String): RoundedPolygon = getSmallButtonShape(shapeName) + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +fun getMiniPlayerThumbnailShape(shapeName: String): RoundedPolygon = getSmallButtonShape(shapeName) \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/StringUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/StringUtils.kt new file mode 100644 index 00000000..348e5d02 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/StringUtils.kt @@ -0,0 +1,31 @@ +package com.darkxvenom.airbeats.utils + +import java.math.BigInteger +import java.security.MessageDigest + +fun makeTimeString(duration: Long?): String { + if (duration == null || duration < 0) return "" + var sec = duration / 1000 + val day = sec / 86400 + sec %= 86400 + val hour = sec / 3600 + sec %= 3600 + val minute = sec / 60 + sec %= 60 + return when { + day > 0 -> "%d:%02d:%02d:%02d".format(day, hour, minute, sec) + hour > 0 -> "%d:%02d:%02d".format(hour, minute, sec) + else -> "%d:%02d".format(minute, sec) + } +} + +fun md5(str: String): String { + val md = MessageDigest.getInstance("MD5") + return BigInteger(1, md.digest(str.toByteArray())).toString(16).padStart(32, '0') +} + +fun joinByBullet(vararg str: String?) = + str + .filterNot { + it.isNullOrEmpty() + }.joinToString(separator = " • ") diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/SyncUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/SyncUtils.kt new file mode 100644 index 00000000..38766c46 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/SyncUtils.kt @@ -0,0 +1,173 @@ +package com.darkxvenom.airbeats.utils + +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.innertube.models.ArtistItem +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.utils.completed +import com.darkxvenom.airbeats.innertube.utils.completedLibraryPage +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.ArtistEntity +import com.darkxvenom.airbeats.db.entities.PlaylistEntity +import com.darkxvenom.airbeats.db.entities.PlaylistSongMap +import com.darkxvenom.airbeats.db.entities.SongEntity +import com.darkxvenom.airbeats.models.toMediaMetadata +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import java.time.LocalDateTime +import javax.inject.Inject +import javax.inject.Singleton + + +@Singleton +class SyncUtils @Inject constructor( + val database: MusicDatabase, +) { + suspend fun syncLikedSongs() { + YouTube.playlist("LM").completed().onSuccess { page -> + val songs = page.songs.reversed() + + database.likedSongsByNameAsc().first() + .filterNot { it.id in songs.map(SongItem::id) } + .forEach { database.update(it.song.localToggleLike()) } + + songs.forEach { song -> + val dbSong = database.song(song.id).firstOrNull() + database.transaction { + when (dbSong) { + null -> insert(song.toMediaMetadata(), SongEntity::localToggleLike) + else -> if (!dbSong.song.liked) update(dbSong.song.localToggleLike()) + } + } + } + } + } + + suspend fun syncLibrarySongs() { + YouTube.library("FEmusic_liked_videos").completedLibraryPage().onSuccess { page -> + val songs = page.items.filterIsInstance().reversed() + + database.songsByNameAsc().first() + .filterNot { it.id in songs.map(SongItem::id) } + .forEach { database.update(it.song.toggleLibrary()) } + + songs.forEach { song -> + val dbSong = database.song(song.id).firstOrNull() + database.transaction { + when (dbSong) { + null -> insert(song.toMediaMetadata(), SongEntity::toggleLibrary) + else -> if (dbSong.song.inLibrary == null) update(dbSong.song.toggleLibrary()) + } + } + } + } + } + + suspend fun syncLikedAlbums() { + YouTube.library("FEmusic_liked_albums").completedLibraryPage().onSuccess { page -> + val albums = page.items.filterIsInstance().reversed() + + database.albumsLikedByNameAsc().first() + .filterNot { it.id in albums.map(AlbumItem::id) } + .forEach { database.update(it.album.localToggleLike()) } + + albums.forEach { album -> + val dbAlbum = database.album(album.id).firstOrNull() + YouTube.album(album.browseId).onSuccess { albumPage -> + when (dbAlbum) { + null -> { + database.insert(albumPage) + database.album(album.id).firstOrNull()?.let { + database.update(it.album.localToggleLike()) + } + } + else -> if (dbAlbum.album.bookmarkedAt == null) + database.update(dbAlbum.album.localToggleLike()) + } + } + } + } + } + + suspend fun syncArtistsSubscriptions() { + YouTube.library("FEmusic_library_corpus_artists").completedLibraryPage().onSuccess { page -> + val artists = page.items.filterIsInstance() + + database.artistsBookmarkedByNameAsc().first() + .filterNot { it.id in artists.map(ArtistItem::id) } + .forEach { database.update(it.artist.localToggleLike()) } + + artists.forEach { artist -> + val dbArtist = database.artist(artist.id).firstOrNull() + database.transaction { + when (dbArtist) { + null -> { + insert( + ArtistEntity( + id = artist.id, + name = artist.title, + thumbnailUrl = artist.thumbnail, + channelId = artist.channelId, + bookmarkedAt = LocalDateTime.now() + ) + ) + } + else -> if (dbArtist.artist.bookmarkedAt == null) + update(dbArtist.artist.localToggleLike()) + } + } + } + } + } + + suspend fun syncSavedPlaylists() { + YouTube.library("FEmusic_liked_playlists").completedLibraryPage().onSuccess { page -> + val playlistList = page.items.filterIsInstance() + .filterNot { it.id == "LM" || it.id == "SE" } + .reversed() + val dbPlaylists = database.playlistsByNameAsc().first() + + dbPlaylists.filterNot { it.playlist.browseId in playlistList.map(PlaylistItem::id) } + .filterNot { it.playlist.browseId == null } + .forEach { database.update(it.playlist.localToggleLike()) } + + playlistList.onEach { playlist -> + var playlistEntity = dbPlaylists.find { playlist.id == it.playlist.browseId }?.playlist + if (playlistEntity == null) { + playlistEntity = PlaylistEntity( + name = playlist.title, + browseId = playlist.id, + isEditable = playlist.isEditable, + bookmarkedAt = LocalDateTime.now(), + remoteSongCount = playlist.songCountText?.let { + Regex("""\d+""").find(it)?.value?.toIntOrNull() + } + ) + + database.insert(playlistEntity) + } else database.update(playlistEntity, playlist) + + syncPlaylist(playlist.id, playlistEntity.id) + } + } + } + + suspend fun syncPlaylist(browseId: String, playlistId: String) { + val playlistPage = YouTube.playlist(browseId).completed().getOrNull() ?: return + database.transaction { + clearPlaylist(playlistId) + playlistPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { position, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlistId, + position = position, + setVideoId = song.setVideoId + ) + }.forEach(::insert) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/Updater.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/Updater.kt new file mode 100644 index 00000000..8e05f460 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/Updater.kt @@ -0,0 +1,23 @@ +package com.darkxvenom.airbeats.utils + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import org.json.JSONObject + +object Updater { + private val client = HttpClient() + var lastCheckTime = -1L + private set + + suspend fun getLatestVersionName(): Result = + runCatching { + val response = + client.get("https://api.github.com/repos/darkxvenom/airbeats/releases/latest") + .bodyAsText() + val json = JSONObject(response) + val versionName = json.getString("name") + lastCheckTime = System.currentTimeMillis() + versionName + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/Utils.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/Utils.kt new file mode 100644 index 00000000..282ccab1 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/Utils.kt @@ -0,0 +1,5 @@ +package com.darkxvenom.airbeats.utils + +fun reportException(throwable: Throwable) { + throwable.printStackTrace() +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/YTPlayerUtils.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/YTPlayerUtils.kt new file mode 100644 index 00000000..98675df3 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/YTPlayerUtils.kt @@ -0,0 +1,334 @@ +package com.darkxvenom.airbeats.utils + +import android.net.ConnectivityManager +import androidx.media3.common.PlaybackException +import com.darkxvenom.airbeats.innertube.NewPipeUtils +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.YouTubeClient +import com.darkxvenom.airbeats.innertube.models.YouTubeClient.Companion.ANDROID_VR_NO_AUTH +import com.darkxvenom.airbeats.innertube.models.YouTubeClient.Companion.IOS +import com.darkxvenom.airbeats.innertube.models.YouTubeClient.Companion.MOBILE +import com.darkxvenom.airbeats.innertube.models.YouTubeClient.Companion.TVHTML5_SIMPLY_EMBEDDED_PLAYER +import com.darkxvenom.airbeats.innertube.models.YouTubeClient.Companion.WEB +import com.darkxvenom.airbeats.innertube.models.YouTubeClient.Companion.WEB_CREATOR +import com.darkxvenom.airbeats.innertube.models.YouTubeClient.Companion.WEB_REMIX +import com.darkxvenom.airbeats.innertube.models.response.PlayerResponse +import com.darkxvenom.airbeats.constants.AudioQuality +import okhttp3.OkHttpClient +import timber.log.Timber + +object YTPlayerUtils { + private const val logTag = "YTPlayerUtils" + + private val httpClient = OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() + + /** + * The main client is used for metadata and initial streams. Do not use + * other clients for this because it can result in inconsistent metadata. + * For example other clients can have different normalization targets + * (loudnessDb). Creditos completos de el commit completo a metrolist. + * + * [com.metrolist.innertube.models.YouTubeClient.WEB_REMIX] should be + * preferred here because currently it is the only client which provides: + * - the correct metadata (like loudnessDb) + * - premium formats + */ + private val MAIN_CLIENT: YouTubeClient = WEB_REMIX + + /** + * Clients used for fallback streams in case the streams of the main client + * do not work. + */ + private val STREAM_FALLBACK_CLIENTS: Array = arrayOf( + ANDROID_VR_NO_AUTH, + TVHTML5_SIMPLY_EMBEDDED_PLAYER, + IOS, + WEB, + WEB_CREATOR, + MOBILE + ) + + data class PlaybackData( + val audioConfig: PlayerResponse.PlayerConfig.AudioConfig?, + val videoDetails: PlayerResponse.VideoDetails?, + val format: PlayerResponse.StreamingData.Format, + val streamUrl: String, + val streamExpiresInSeconds: Int, + ) + + /** + * Custom player response intended to use for playback. Metadata like + * audioConfig and videoDetails are from [MAIN_CLIENT]. Format & stream can + * be from [MAIN_CLIENT] or [STREAM_FALLBACK_CLIENTS]. + */ + suspend fun playerResponseForPlayback( + videoId: String, + playlistId: String? = null, + audioQuality: AudioQuality, + connectivityManager: ConnectivityManager, + ): Result = runCatching { + Timber.tag(logTag) + .d("Fetching player response for videoId: $videoId, playlistId: $playlistId") + /** + * This is required for some clients to get working streams however it + * should not be forced for the [MAIN_CLIENT] because the response of the + * [MAIN_CLIENT] is required even if the streams won't work from this + * client. This is why it is allowed to be null. + */ + val signatureTimestamp = getSignatureTimestampOrNull(videoId) + Timber.tag(logTag).d("Signature timestamp: $signatureTimestamp") + + val isLoggedIn = YouTube.cookie != null + val sessionId = + if (isLoggedIn) { + // signed in sessions use dataSyncId as identifier + YouTube.dataSyncId + } else { + // signed out sessions use visitorData as identifier + YouTube.visitorData + } + Timber.tag(logTag) + .d("Session authentication status: ${if (isLoggedIn) "Logged in" else "Not logged in"}") + + Timber.tag(logTag) + .d("Attempting to get player response using MAIN_CLIENT: ${MAIN_CLIENT.clientName}") + val mainPlayerResponse = + YouTube.player(videoId, playlistId, MAIN_CLIENT, signatureTimestamp).getOrThrow() + val audioConfig = mainPlayerResponse.playerConfig?.audioConfig + val videoDetails = mainPlayerResponse.videoDetails + var format: PlayerResponse.StreamingData.Format? = null + var streamUrl: String? = null + var streamExpiresInSeconds: Int? = null + var streamPlayerResponse: PlayerResponse? = null + + for (clientIndex in (-1 until STREAM_FALLBACK_CLIENTS.size)) { + // reset for each client + format = null + streamUrl = null + streamExpiresInSeconds = null + + // decide which client to use for streams and load its player response + val client: YouTubeClient + if (clientIndex == -1) { + // try with streams from main client first + client = MAIN_CLIENT + streamPlayerResponse = mainPlayerResponse + Timber.tag(logTag).d("Trying stream from MAIN_CLIENT: ${client.clientName}") + } else { + // after main client use fallback clients + client = STREAM_FALLBACK_CLIENTS[clientIndex] + Timber.tag(logTag) + .d("Trying fallback client ${clientIndex + 1}/${STREAM_FALLBACK_CLIENTS.size}: ${client.clientName}") + + if (client.loginRequired && !isLoggedIn && YouTube.cookie == null) { + // skip client if it requires login but user is not logged in + Timber.tag(logTag) + .d("Skipping client ${client.clientName} - requires login but user is not logged in") + continue + } + + Timber.tag(logTag) + .d("Fetching player response for fallback client: ${client.clientName}") + streamPlayerResponse = + YouTube.player(videoId, playlistId, client, signatureTimestamp).getOrNull() + } + + // process current client response + if (streamPlayerResponse?.playabilityStatus?.status == "OK") { + Timber.tag(logTag) + .d("Player response status OK for client: ${if (clientIndex == -1) MAIN_CLIENT.clientName else STREAM_FALLBACK_CLIENTS[clientIndex].clientName}") + + format = + findFormat( + streamPlayerResponse, + audioQuality, + connectivityManager, + ) + + if (format == null) { + Timber.tag(logTag) + .d("No suitable format found for client: ${if (clientIndex == -1) MAIN_CLIENT.clientName else STREAM_FALLBACK_CLIENTS[clientIndex].clientName}") + continue + } + + Timber.tag(logTag).d("Format found: ${format.mimeType}, bitrate: ${format.bitrate}") + + streamUrl = findUrlOrNull(format, videoId) + if (streamUrl == null) { + Timber.tag(logTag).d("Stream URL not found for format") + continue + } + + streamExpiresInSeconds = streamPlayerResponse.streamingData?.expiresInSeconds + if (streamExpiresInSeconds == null) { + Timber.tag(logTag).d("Stream expiration time not found") + continue + } + + Timber.tag(logTag).d("Stream expires in: $streamExpiresInSeconds seconds") + + if (clientIndex == STREAM_FALLBACK_CLIENTS.size - 1) { + /** skip [validateStatus] for last client */ + Timber.tag(logTag) + .d("Using last fallback client without validation: ${STREAM_FALLBACK_CLIENTS[clientIndex].clientName}") + break + } + + if (validateStatus(streamUrl)) { + // working stream found + Timber.tag(logTag) + .d("Stream validated successfully with client: ${if (clientIndex == -1) MAIN_CLIENT.clientName else STREAM_FALLBACK_CLIENTS[clientIndex].clientName}") + break + } else { + Timber.tag(logTag) + .d("Stream validation failed for client: ${if (clientIndex == -1) MAIN_CLIENT.clientName else STREAM_FALLBACK_CLIENTS[clientIndex].clientName}") + } + } else { + Timber.tag(logTag) + .d("Player response status not OK: ${streamPlayerResponse?.playabilityStatus?.status}, reason: ${streamPlayerResponse?.playabilityStatus?.reason}") + } + } + + if (streamPlayerResponse == null) { + Timber.tag(logTag).e("Bad stream player response - all clients failed") + throw Exception("Bad stream player response") + } + + if (streamPlayerResponse.playabilityStatus.status != "OK") { + val errorReason = streamPlayerResponse.playabilityStatus.reason + Timber.tag(logTag).e("Playability status not OK: $errorReason") + throw PlaybackException( + errorReason, + null, + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + } + + if (streamExpiresInSeconds == null) { + Timber.tag(logTag).e("Missing stream expire time") + throw Exception("Missing stream expire time") + } + + if (format == null) { + Timber.tag(logTag).e("Could not find format") + throw Exception("Could not find format") + } + + if (streamUrl == null) { + Timber.tag(logTag).e("Could not find stream url") + throw Exception("Could not find stream url") + } + + Timber.tag(logTag) + .d("Successfully obtained playback data with format: ${format.mimeType}, bitrate: ${format.bitrate}") + PlaybackData( + audioConfig, + videoDetails, + format, + streamUrl, + streamExpiresInSeconds, + ) + } + + /** + * Simple player response intended to use for metadata only. Stream URLs of + * this response might not work so don't use them. + */ + suspend fun playerResponseForMetadata( + videoId: String, + playlistId: String? = null, + ): Result { + Timber.tag(logTag) + .d("Fetching metadata-only player response for videoId: $videoId using MAIN_CLIENT: ${MAIN_CLIENT.clientName}") + return YouTube.player(videoId, playlistId, client = MAIN_CLIENT) + .onSuccess { Timber.tag(logTag).d("Successfully fetched metadata") } + .onFailure { Timber.tag(logTag).e(it, "Failed to fetch metadata") } + } + + private fun findFormat( + playerResponse: PlayerResponse, + audioQuality: AudioQuality, + connectivityManager: ConnectivityManager, + ): PlayerResponse.StreamingData.Format? { + Timber.tag(logTag) + .d("Finding format with audioQuality: $audioQuality, network metered: ${connectivityManager.isActiveNetworkMetered}") + + val format = playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { + it.bitrate * when (audioQuality) { + AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 + AudioQuality.HIGH -> 1 + AudioQuality.LOW -> -1 + } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream + } + + if (format != null) { + Timber.tag(logTag).d("Selected format: ${format.mimeType}, bitrate: ${format.bitrate}") + } else { + Timber.tag(logTag).d("No suitable audio format found") + } + + return format + } + + /** + * Checks if the stream url returns a successful status. If this returns + * true the url is likely to work. If this returns false the url might + * cause an error during playback. + */ + private fun validateStatus(url: String): Boolean { + Timber.tag(logTag).d("Validating stream URL status") + try { + val requestBuilder = okhttp3.Request.Builder() + .head() + .url(url) + val response = httpClient.newCall(requestBuilder.build()).execute() + val isSuccessful = response.isSuccessful + Timber.tag(logTag) + .d("Stream URL validation result: ${if (isSuccessful) "Success" else "Failed"} (${response.code})") + return isSuccessful + } catch (e: Exception) { + Timber.tag(logTag).e(e, "Stream URL validation failed with exception") + reportException(e) + } + return false + } + + /** + * Wrapper around the [NewPipeUtils.getSignatureTimestamp] function which + * reports exceptions + */ + private fun getSignatureTimestampOrNull( + videoId: String + ): Int? { + Timber.tag(logTag).d("Getting signature timestamp for videoId: $videoId") + return NewPipeUtils.getSignatureTimestamp(videoId) + .onSuccess { Timber.tag(logTag).d("Signature timestamp obtained: $it") } + .onFailure { + Timber.tag(logTag).e(it, "Failed to get signature timestamp") + reportException(it) + } + .getOrNull() + } + + /** + * Wrapper around the [NewPipeUtils.getStreamUrl] function which reports + * exceptions + */ + private fun findUrlOrNull( + format: PlayerResponse.StreamingData.Format, + videoId: String + ): String? { + Timber.tag(logTag).d("Finding stream URL for format: ${format.mimeType}, videoId: $videoId") + return NewPipeUtils.getStreamUrl(format, videoId) + .onSuccess { Timber.tag(logTag).d("Stream URL obtained successfully") } + .onFailure { + Timber.tag(logTag).e(it, "Failed to get stream URL") + reportException(it) + } + .getOrNull() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/JavaScriptUtil.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/JavaScriptUtil.kt new file mode 100644 index 00000000..d504549f --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/JavaScriptUtil.kt @@ -0,0 +1,137 @@ +package com.darkxvenom.airbeats.utils.potoken + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString + +/** + * Parses the raw challenge data obtained from the Create endpoint and + * returns an object that can be embedded in a JavaScript snippet. + */ +fun parseChallengeData(rawChallengeData: String): String { + val scrambled = Json.parseToJsonElement(rawChallengeData).jsonArray + + val challengeData = if (scrambled.size > 1 && scrambled[1].jsonPrimitive.isString) { + val descrambled = descramble(scrambled[1].jsonPrimitive.content) + Json.parseToJsonElement(descrambled).jsonArray + } else { + scrambled[1].jsonArray + } + + val messageId = challengeData[0].jsonPrimitive.content + val interpreterHash = challengeData[3].jsonPrimitive.content + val program = challengeData[4].jsonPrimitive.content + val globalName = challengeData[5].jsonPrimitive.content + val clientExperimentsStateBlob = challengeData[7].jsonPrimitive.content + + val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData[1] + .takeIf { it !is JsonNull } + ?.jsonArray + ?.find { it.jsonPrimitive.isString } + val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData[2] + .takeIf { it !is JsonNull } + ?.jsonArray + ?.find { it.jsonPrimitive.isString } + + return Json.encodeToString( + JsonObject.serializer(), JsonObject( + mapOf( + "messageId" to JsonPrimitive(messageId), + "interpreterJavascript" to JsonObject( + mapOf( + "privateDoNotAccessOrElseSafeScriptWrappedValue" to (privateDoNotAccessOrElseSafeScriptWrappedValue + ?: JsonNull), + "privateDoNotAccessOrElseTrustedResourceUrlWrappedValue" to (privateDoNotAccessOrElseTrustedResourceUrlWrappedValue + ?: JsonNull) + ) + ), + "interpreterHash" to JsonPrimitive(interpreterHash), + "program" to JsonPrimitive(program), + "globalName" to JsonPrimitive(globalName), + "clientExperimentsStateBlob" to JsonPrimitive(clientExperimentsStateBlob) + ) + ) + ) +} + +/** + * Parses the raw integrity token data obtained from the GenerateIT + * endpoint to a JavaScript `Uint8Array` that can be embedded directly in + * JavaScript code, and an [Int] representing the duration of this token in + * seconds. + */ +fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair { + val integrityTokenData = Json.parseToJsonElement(rawIntegrityTokenData).jsonArray + return base64ToU8(integrityTokenData[0].jsonPrimitive.content) to integrityTokenData[1].jsonPrimitive.long +} + +/** + * Converts a string (usually the identifier used as input to + * `obtainPoToken`) to a JavaScript `Uint8Array` that can be embedded + * directly in JavaScript code. + */ +fun stringToU8(identifier: String): String { + return newUint8Array(identifier.toByteArray()) +} + +/** + * Takes a poToken encoded as a sequence of bytes represented as integers + * separated by commas (e.g. "97,98,99" would be "abc"), which is the + * output of `Uint8Array::toString()` in JavaScript, and converts it to the + * specific base64 representation for poTokens. + */ +fun u8ToBase64(poToken: String): String { + return poToken.split(",") + .map { it.toUByte().toByte() } + .toByteArray() + .toByteString() + .base64() + .replace("+", "-") + .replace("/", "_") +} + +/** + * Takes the scrambled challenge, decodes it from base64, adds 97 to each + * byte. + */ +private fun descramble(scrambledChallenge: String): String { + return base64ToByteString(scrambledChallenge) + .map { (it + 97).toByte() } + .toByteArray() + .decodeToString() +} + +/** + * Decodes a base64 string encoded in the specific base64 representation + * used by YouTube, and returns a JavaScript `Uint8Array` that can be + * embedded directly in JavaScript code. + */ +private fun base64ToU8(base64: String): String { + return newUint8Array(base64ToByteString(base64)) +} + +private fun newUint8Array(contents: ByteArray): String { + return "new Uint8Array([" + contents.joinToString(separator = ",") { + it.toUByte().toString() + } + "])" +} + +/** + * Decodes a base64 string encoded in the specific base64 representation + * used by YouTube. + */ +private fun base64ToByteString(base64: String): ByteArray { + val base64Mod = base64 + .replace('-', '+') + .replace('_', '/') + .replace('.', '=') + + return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode")) + .toByteArray() +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenException.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenException.kt new file mode 100644 index 00000000..7c0aa55c --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenException.kt @@ -0,0 +1,13 @@ +package com.darkxvenom.airbeats.utils.potoken + +class PoTokenException(message: String) : Exception(message) + +// to be thrown if the WebView provided by the system is broken +class BadWebViewException(message: String) : Exception(message) + +fun buildExceptionForJsError(error: String): Exception { + return if (error.contains("SyntaxError")) + BadWebViewException(error) + else + PoTokenException(error) +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenGenerator.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenGenerator.kt new file mode 100644 index 00000000..989def26 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenGenerator.kt @@ -0,0 +1,107 @@ +package com.darkxvenom.airbeats.utils.potoken + +import android.util.Log +import android.webkit.CookieManager +import com.darkxvenom.airbeats.App +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class PoTokenGenerator { + private val TAG = "PoTokenGenerator" + + private val webViewSupported by lazy { runCatching { CookieManager.getInstance() }.isSuccess } + private var webViewBadImpl = false // whether the system has a bad WebView implementation + + private val webPoTokenGenLock = Mutex() + private var webPoTokenSessionId: String? = null + private var webPoTokenStreamingPot: String? = null + private var webPoTokenGenerator: PoTokenWebView? = null + + fun getWebClientPoToken(videoId: String, sessionId: String): PoTokenResult? { + if (!webViewSupported || webViewBadImpl) { + return null + } + + return try { + runBlocking { getWebClientPoToken(videoId, sessionId, forceRecreate = false) } + } catch (e: Exception) { + when (e) { + is BadWebViewException -> { + Log.e(TAG, "Could not obtain poToken because WebView is broken", e) + webViewBadImpl = true + null + } + + else -> throw e // includes PoTokenException + } + } + } + + /** + * @param forceRecreate whether to force the recreation of + * [webPoTokenGenerator], to be used in case the current + * [webPoTokenGenerator] threw an error last time + * [PoTokenWebView.generatePoToken] was called + */ + private suspend fun getWebClientPoToken( + videoId: String, + sessionId: String, + forceRecreate: Boolean + ): PoTokenResult { + Log.d(TAG, "Web poToken requested: $videoId, $sessionId") + + val (poTokenGenerator, streamingPot, hasBeenRecreated) = + webPoTokenGenLock.withLock { + val shouldRecreate = + forceRecreate || webPoTokenGenerator == null || webPoTokenGenerator!!.isExpired || webPoTokenSessionId != sessionId + + if (shouldRecreate) { + webPoTokenSessionId = sessionId + + withContext(Dispatchers.Main) { + webPoTokenGenerator?.close() + } + + // create a new webPoTokenGenerator + webPoTokenGenerator = PoTokenWebView.getNewPoTokenGenerator(App.instance) + + // The streaming poToken needs to be generated exactly once before generating + // any other (player) tokens. + webPoTokenStreamingPot = + webPoTokenGenerator!!.generatePoToken(webPoTokenSessionId!!) + } + + Triple(webPoTokenGenerator!!, webPoTokenStreamingPot!!, shouldRecreate) + } + + val playerPot = try { + // Not using synchronized here, since poTokenGenerator would be able to generate + // multiple poTokens in parallel if needed. The only important thing is for exactly one + // streaming poToken (based on [sessionId]) to be generated before anything else. + poTokenGenerator.generatePoToken(videoId) + } catch (throwable: Throwable) { + if (hasBeenRecreated) { + // the poTokenGenerator has just been recreated (and possibly this is already the + // second time we try), so there is likely nothing we can do + throw throwable + } else { + // retry, this time recreating the [webPoTokenGenerator] from scratch; + // this might happen for example if the app goes in the background and the WebView + // content is lost + Log.e(TAG, "Failed to obtain poToken, retrying", throwable) + return getWebClientPoToken( + videoId = videoId, + sessionId = sessionId, + forceRecreate = true + ) + } + } + + Log.d(TAG, "[$videoId] playerPot=$playerPot, streamingPot=$streamingPot") + + return PoTokenResult(playerPot, streamingPot) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenResult.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenResult.kt new file mode 100644 index 00000000..cd947438 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenResult.kt @@ -0,0 +1,6 @@ +package com.darkxvenom.airbeats.utils.potoken + +class PoTokenResult( + val playerRequestPoToken: String, + val streamingDataPoToken: String, +) \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenWebView.kt b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenWebView.kt new file mode 100644 index 00000000..fbf28b41 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/utils/potoken/PoTokenWebView.kt @@ -0,0 +1,353 @@ +package com.darkxvenom.airbeats.utils.potoken + +import android.content.Context +import android.util.Log +import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebView +import androidx.annotation.MainThread +import androidx.collection.ArrayMap +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.BuildConfig +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Headers.Companion.toHeaders +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Collections +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class PoTokenWebView private constructor( + context: Context, + // to be used exactly once only during initialization! + private val continuation: Continuation, +) { + private val webView = WebView(context) + private val scope = MainScope() + private val poTokenContinuations = + Collections.synchronizedMap(ArrayMap>()) + private val exceptionHandler = CoroutineExceptionHandler { _, t -> + onInitializationErrorCloseAndCancel(t) + } + private lateinit var expirationInstant: Instant + + //region Initialization + init { + val webViewSettings = webView.settings + //noinspection SetJavaScriptEnabled we want to use JavaScript! + webViewSettings.javaScriptEnabled = true + webViewSettings.safeBrowsingEnabled = false + webViewSettings.userAgentString = USER_AGENT + webViewSettings.blockNetworkLoads = true // the WebView does not need internet access + + // so that we can run async functions and get back the result + webView.addJavascriptInterface(this, JS_INTERFACE) + + webView.webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(m: ConsoleMessage): Boolean { + if (m.message().contains("Uncaught")) { + // There should not be any uncaught errors while executing the code, because + // everything that can fail is guarded by try-catch. Therefore, this likely + // indicates that there was a syntax error in the code, i.e. the WebView only + // supports a really old version of JS. + + val fmt = "\"${m.message()}\", source: ${m.sourceId()} (${m.lineNumber()})" + val exception = BadWebViewException(fmt) + Log.e(TAG, "This WebView implementation is broken: $fmt") + + onInitializationErrorCloseAndCancel(exception) + popAllPoTokenContinuations().forEach { (_, cont) -> + cont.resumeWithException( + exception + ) + } + } + return super.onConsoleMessage(m) + } + } + } + + /** + * Must be called right after instantiating [PoTokenWebView] to perform the + * actual initialization. This will asynchronously go through all the steps + * needed to load BotGuard, run it, and obtain an `integrityToken`. + */ + private fun loadHtmlAndObtainBotguard() { + Log.d(TAG, "loadHtmlAndObtainBotguard() called") + + scope.launch(exceptionHandler) { + val html = withContext(Dispatchers.IO) { + webView.context.assets.open("po_token.html").bufferedReader().use { it.readText() } + } + + // calls downloadAndRunBotguard() when the page has finished loading + val data = + html.replaceFirst("", "\n$JS_INTERFACE.downloadAndRunBotguard()") + webView.loadDataWithBaseURL("https://www.youtube.com", data, "text/html", "utf-8", null) + } + } + + /** + * Called during initialization by the JavaScript snippet appended to the + * HTML page content in [loadHtmlAndObtainBotguard] after the WebView + * content has been loaded. + */ + @JavascriptInterface + fun downloadAndRunBotguard() { + Log.d(TAG, "downloadAndRunBotguard() called") + + makeBotguardServiceRequest( + "https://www.youtube.com/api/jnn/v1/Create", + "[ \"$REQUEST_KEY\" ]", + ) { responseBody -> + val parsedChallengeData = parseChallengeData(responseBody) + webView.evaluateJavascript( + """try { + data = $parsedChallengeData + runBotGuard(data).then(function (result) { + this.webPoSignalOutput = result.webPoSignalOutput + $JS_INTERFACE.onRunBotguardResult(result.botguardResponse) + }, function (error) { + $JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack) + }) + } catch (error) { + $JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack) + }""", + null + ) + } + } + + /** + * Called during initialization by the JavaScript snippets from either + * [downloadAndRunBotguard] or [onRunBotguardResult]. + */ + @JavascriptInterface + fun onJsInitializationError(error: String) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "Initialization error from JavaScript: $error") + } + onInitializationErrorCloseAndCancel(buildExceptionForJsError(error)) + } + + /** + * Called during initialization by the JavaScript snippet from + * [downloadAndRunBotguard] after obtaining the BotGuard execution output + * [botguardResponse]. + */ + @JavascriptInterface + fun onRunBotguardResult(botguardResponse: String) { + Log.d(TAG, "botguardResponse: $botguardResponse") + makeBotguardServiceRequest( + "https://www.youtube.com/api/jnn/v1/GenerateIT", + "[ \"$REQUEST_KEY\", \"$botguardResponse\" ]", + ) { responseBody -> + Log.d(TAG, "GenerateIT response: $responseBody") + val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody) + + // leave 10 minutes of margin just to be sure + expirationInstant = + Instant.now().plusSeconds(expirationTimeInSeconds).minus(10, ChronoUnit.MINUTES) + + webView.evaluateJavascript("this.integrityToken = $integrityToken") { + Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s") + continuation.resume(this) + } + } + } + //endregion + + //region Obtaining poTokens + suspend fun generatePoToken(identifier: String): String { + return withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + Log.d(TAG, "generatePoToken() called with identifier $identifier") + addPoTokenEmitter(identifier, cont) + webView.evaluateJavascript( + """try { + identifier = "$identifier" + u8Identifier = ${stringToU8(identifier)} + poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier) + poTokenU8String = poTokenU8.join(",") + $JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String) + } catch (error) { + $JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack) + }""", + null + ) + } + } + } + + /** + * Called by the JavaScript snippet from [generatePoToken] when an error + * occurs in calling the JavaScript `obtainPoToken()` function. + */ + @JavascriptInterface + fun onObtainPoTokenError(identifier: String, error: String) { + if (BuildConfig.DEBUG) { + Log.e(TAG, "obtainPoToken error from JavaScript: $error") + } + popPoTokenContinuation(identifier)?.resumeWithException(buildExceptionForJsError(error)) + } + + /** + * Called by the JavaScript snippet from [generatePoToken] with the + * original identifier and the result of the JavaScript `obtainPoToken()` + * function. + */ + @JavascriptInterface + fun onObtainPoTokenResult(identifier: String, poTokenU8: String) { + Log.d( + TAG, + "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8" + ) + val poToken = try { + u8ToBase64(poTokenU8) + } catch (t: Throwable) { + popPoTokenContinuation(identifier)?.resumeWithException(t) + return + } + + Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken") + popPoTokenContinuation(identifier)?.resume(poToken) + } + + val isExpired: Boolean + get() = Instant.now().isAfter(expirationInstant) + //endregion + + //region Handling multiple emitters + /** + * Adds the ([identifier], [continuation]) pair to the + * [poTokenContinuations] list. This makes it so that multiple poToken + * requests can be generated in parallel, and the results will be notified + * to the right continuations. + */ + private fun addPoTokenEmitter(identifier: String, continuation: Continuation) { + poTokenContinuations[identifier] = continuation + } + + /** + * Extracts and removes from the [poTokenContinuations] list a + * [Continuation] based on its [identifier]. The continuation is supposed + * to be used immediately after to either signal a success or an error. + */ + private fun popPoTokenContinuation(identifier: String): Continuation? { + return poTokenContinuations.remove(identifier) + } + + /** + * Clears [poTokenContinuations] and returns its previous contents. The + * continuations are supposed to be used immediately after to either signal + * a success or an error. + */ + private fun popAllPoTokenContinuations(): Map> { + val result = poTokenContinuations.toMap() + poTokenContinuations.clear() + return result + } + //endregion + + //region Utils + /** + * Makes a POST request to [url] with the given [data] by setting the + * correct headers. Calls [onInitializationErrorCloseAndCancel] in case + * of any network errors and also if the response does not have HTTP code + * 200, therefore this is supposed to be used only during initialization. + * Calls [handleResponseBody] with the response body if the response is + * successful. The request is performed in the background and a disposable + * is added to [disposables]. + */ + private fun makeBotguardServiceRequest( + url: String, + data: String, + handleResponseBody: (String) -> Unit, + ) { + scope.launch(exceptionHandler) { + val requestBuilder = okhttp3.Request.Builder() + .post(data.toRequestBody()) + .headers( + mapOf( + "User-Agent" to USER_AGENT, + "Accept" to "application/json", + "Content-Type" to "application/json+protobuf", + "x-goog-api-key" to GOOGLE_API_KEY, + "x-user-agent" to "grpc-web-javascript/0.1", + ).toHeaders() + ) + .url(url) + val response = withContext(Dispatchers.IO) { + httpClient.newCall(requestBuilder.build()).execute() + } + val httpCode = response.code + if (httpCode != 200) { + onInitializationErrorCloseAndCancel(PoTokenException("Invalid response code: $httpCode")) + } else { + val body = withContext(Dispatchers.IO) { + response.body!!.string() + } + handleResponseBody(body) + } + } + } + + /** + * Handles any error happening during initialization, releasing resources + * and sending the error to [continuation]. + */ + private fun onInitializationErrorCloseAndCancel(error: Throwable) { + close() + continuation.resumeWithException(error) + } + + /** Releases all [webView] resources. */ + @MainThread + fun close() { + scope.cancel() + + webView.clearHistory() + // clears RAM cache and disk cache (globally for all WebViews) + webView.clearCache(true) + + // ensures that the WebView isn't doing anything when destroying it + webView.loadUrl("about:blank") + + webView.onPause() + webView.removeAllViews() + webView.destroy() + } + //endregion + + companion object { + private const val TAG = "PoTokenWebView" + private const val GOOGLE_API_KEY = "YOUR_GOOGLE_API_KEY" + private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo" + private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3" + private const val JS_INTERFACE = "PoTokenWebView" + + private val httpClient = OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() + + suspend fun getNewPoTokenGenerator(context: Context): PoTokenWebView { + return withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + val potWv = PoTokenWebView(context, cont) + potWv.loadHtmlAndObtainBotguard() + } + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/AccountViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/AccountViewModel.kt new file mode 100644 index 00000000..f239af84 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/AccountViewModel.kt @@ -0,0 +1,42 @@ +package com.darkxvenom.airbeats.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.innertube.models.ArtistItem +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.utils.completedLibraryPage +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AccountViewModel @Inject constructor() : ViewModel() { + val playlists = MutableStateFlow?>(null) + val albums = MutableStateFlow?>(null) + val artists = MutableStateFlow?>(null) + + init { + viewModelScope.launch { + YouTube.library("FEmusic_liked_playlists").completedLibraryPage().onSuccess { + playlists.value = it.items.filterIsInstance() + .filterNot { it.id == "SE" } + }.onFailure { + reportException(it) + } + YouTube.library("FEmusic_liked_albums").completedLibraryPage().onSuccess { + albums.value = it.items.filterIsInstance() + }.onFailure { + reportException(it) + } + YouTube.library("FEmusic_library_corpus_artists").completedLibraryPage().onSuccess { + artists.value = it.items.filterIsInstance() + }.onFailure { + reportException(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/AlbumViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/AlbumViewModel.kt new file mode 100644 index 00000000..f13a50ee --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/AlbumViewModel.kt @@ -0,0 +1,58 @@ +package com.darkxvenom.airbeats.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AlbumViewModel +@Inject +constructor( + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val albumId = savedStateHandle.get("albumId")!! + val playlistId = MutableStateFlow("") + val albumWithSongs = + database + .albumWithSongs(albumId) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + var otherVersions = MutableStateFlow>(emptyList()) + + init { + viewModelScope.launch { + val album = database.album(albumId).first() + YouTube + .album(albumId) + .onSuccess { + playlistId.value = it.album.playlistId + otherVersions.value = it.otherVersions + database.transaction { + if (album == null) { + insert(it) + } else { + update(album.album, it, album.artists) + } + } + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + album?.album?.let(::delete) + } + } + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/ArtistItemsViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/ArtistItemsViewModel.kt new file mode 100644 index 00000000..2170dc88 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/ArtistItemsViewModel.kt @@ -0,0 +1,77 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.BrowseEndpoint +import com.darkxvenom.airbeats.innertube.models.filterExplicit +import com.darkxvenom.airbeats.constants.HideExplicitKey +import com.darkxvenom.airbeats.models.ItemsPage +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ArtistItemsViewModel +@Inject +constructor( + @ApplicationContext val context: Context, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val browseId = savedStateHandle.get("browseId")!! + private val params = savedStateHandle.get("params") + + val title = MutableStateFlow("") + val itemsPage = MutableStateFlow(null) + + init { + viewModelScope.launch { + YouTube + .artistItems( + BrowseEndpoint( + browseId = browseId, + params = params, + ), + ).onSuccess { artistItemsPage -> + title.value = artistItemsPage.title + itemsPage.value = + ItemsPage( + items = artistItemsPage.items.distinctBy { it.id }, + continuation = artistItemsPage.continuation, + ) + }.onFailure { + reportException(it) + } + } + } + + fun loadMore() { + viewModelScope.launch { + val oldItemsPage = itemsPage.value ?: return@launch + val continuation = oldItemsPage.continuation ?: return@launch + YouTube + .artistItemsContinuation(continuation) + .onSuccess { artistItemsContinuationPage -> + itemsPage.update { + ItemsPage( + items = + (oldItemsPage.items + artistItemsContinuationPage.items) + .distinctBy { it.id } + .filterExplicit(context.dataStore.get(HideExplicitKey, false)), + continuation = artistItemsContinuationPage.continuation, + ) + } + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/ArtistViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/ArtistViewModel.kt new file mode 100644 index 00000000..4e769620 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/ArtistViewModel.kt @@ -0,0 +1,45 @@ +package com.darkxvenom.airbeats.viewmodels + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.pages.ArtistPage +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ArtistViewModel @Inject constructor( + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val artistId = savedStateHandle.get("artistId")!! + var artistPage by mutableStateOf(null) + val libraryArtist = database.artist(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + val librarySongs = database.artistSongsPreview(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + fetchArtistsFromYTM() + } + + private fun fetchArtistsFromYTM() { + viewModelScope.launch { + YouTube.artist(artistId) + .onSuccess { + artistPage = it + }.onFailure { + reportException(it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/AutoPlaylistViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/AutoPlaylistViewModel.kt new file mode 100644 index 00000000..b6ff44d0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/AutoPlaylistViewModel.kt @@ -0,0 +1,86 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.exoplayer.offline.Download +import com.darkxvenom.airbeats.constants.SongSortDescendingKey +import com.darkxvenom.airbeats.constants.SongSortType +import com.darkxvenom.airbeats.constants.SongSortTypeKey +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.extensions.reversed +import com.darkxvenom.airbeats.extensions.toEnum +import com.darkxvenom.airbeats.playback.DownloadUtil +import com.darkxvenom.airbeats.utils.SyncUtils +import com.darkxvenom.airbeats.utils.dataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class AutoPlaylistViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + downloadUtil: DownloadUtil, + savedStateHandle: SavedStateHandle, + private val syncUtils: SyncUtils, +) : ViewModel() { + val playlist = savedStateHandle.get("playlist")!! + + @OptIn(ExperimentalCoroutinesApi::class) + val likedSongs = + context.dataStore.data + .map { + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] + ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + when (playlist) { + "liked" -> database.likedSongs(sortType, descending) + "downloaded" -> downloadUtil.downloads.flatMapLatest { downloads -> + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + when (sortType) { + SongSortType.CREATE_DATE -> songs.sortedBy { + downloads[it.id]?.updateTimeMs ?: 0L + } + + SongSortType.NAME -> songs.sortedBy { it.song.title } + SongSortType.ARTIST -> songs.sortedBy { song -> + song.artists.joinToString(separator = "") { it.name } + } + + SongSortType.PLAY_TIME -> songs.sortedBy { it.song.totalPlayTime } + }.reversed(descending) + } + } + + else -> MutableStateFlow(emptyList()) + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun syncLikedSongs() { + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedSongs() } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/BackupRestoreViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/BackupRestoreViewModel.kt new file mode 100644 index 00000000..18bd3dfa --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/BackupRestoreViewModel.kt @@ -0,0 +1,213 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.lifecycle.ViewModel +import com.darkxvenom.airbeats.MainActivity +import com.darkxvenom.airbeats.R +import com.darkxvenom.airbeats.db.InternalDatabase +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.ArtistEntity +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.db.entities.SongEntity +import com.darkxvenom.airbeats.extensions.div +import com.darkxvenom.airbeats.extensions.tryOrNull +import com.darkxvenom.airbeats.extensions.zipInputStream +import com.darkxvenom.airbeats.extensions.zipOutputStream +import com.darkxvenom.airbeats.playback.MusicService +import com.darkxvenom.airbeats.playback.MusicService.Companion.PERSISTENT_QUEUE_FILE +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import javax.inject.Inject +import kotlin.system.exitProcess + +@HiltViewModel +class BackupRestoreViewModel @Inject constructor( + val database: MusicDatabase, +) : ViewModel() { + fun backup(context: Context, uri: Uri) { + runCatching { + context.applicationContext.contentResolver.openOutputStream(uri)?.use { + it.buffered().zipOutputStream().use { outputStream -> + (context.filesDir / "datastore" / SETTINGS_FILENAME).inputStream().buffered() + .use { inputStream -> + outputStream.putNextEntry(ZipEntry(SETTINGS_FILENAME)) + inputStream.copyTo(outputStream) + } + runBlocking(Dispatchers.IO) { + database.checkpoint() + } + FileInputStream(database.openHelper.writableDatabase.path).use { inputStream -> + outputStream.putNextEntry(ZipEntry(InternalDatabase.DB_NAME)) + inputStream.copyTo(outputStream) + } + } + } + }.onSuccess { + Toast.makeText(context, R.string.backup_create_success, Toast.LENGTH_SHORT).show() + }.onFailure { + reportException(it) + Toast.makeText(context, R.string.backup_create_failed, Toast.LENGTH_SHORT).show() + } + } + + fun restore(context: Context, uri: Uri) { + runCatching { + context.applicationContext.contentResolver.openInputStream(uri)?.use { + it.zipInputStream().use { inputStream -> + var entry = tryOrNull { inputStream.nextEntry } // prevent ZipException + while (entry != null) { + when (entry.name) { + SETTINGS_FILENAME -> { + (context.filesDir / "datastore" / SETTINGS_FILENAME).outputStream() + .use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + InternalDatabase.DB_NAME -> { + runBlocking(Dispatchers.IO) { + database.checkpoint() + } + database.close() + FileOutputStream(database.openHelper.writableDatabase.path).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + entry = tryOrNull { inputStream.nextEntry } // prevent ZipException + } + } + } + context.stopService(Intent(context, MusicService::class.java)) + context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() + context.startActivity(Intent(context, MainActivity::class.java)) + exitProcess(0) + }.onFailure { + reportException(it) + Toast.makeText(context, R.string.restore_failed, Toast.LENGTH_SHORT).show() + } + } + + fun importPlaylistFromCsv(context: Context, uri: Uri): ArrayList { + val songs = arrayListOf() + runCatching { + context.contentResolver.openInputStream(uri)?.use { stream -> + val lines = stream.bufferedReader().readLines() + lines.forEachIndexed { _, line -> + val parts = line.split(",").map { it.trim() } + val title = parts[0] + val artistStr = parts[1] + + val artists = artistStr.split(";").map { it.trim() }.map { + ArtistEntity( + id = "", + name = it, + ) + } + val mockSong = Song( + song = SongEntity( + id = "", + title = title, + ), + artists = artists, + ) + songs.add(mockSong) + } + } + } + + if (songs.isEmpty()) { + Toast.makeText( + context, + "No songs found. Invalid file, or perhaps no song matches were found.", + Toast.LENGTH_SHORT + ).show() + } + return songs + } + + fun loadM3UOnline( + context: Context, + uri: Uri, + ): ArrayList { + val songs = ArrayList() + + runCatching { + context.applicationContext.contentResolver.openInputStream(uri)?.use { stream -> + val lines = stream.bufferedReader().readLines() + if (lines.first().startsWith("#EXTM3U")) { + lines.forEachIndexed { _, rawLine -> + if (rawLine.startsWith("#EXTINF:")) { + // maybe later write this to be more efficient + val artists = + rawLine.substringAfter("#EXTINF:").substringAfter(',').substringBefore(" - ").split(';') + val title = rawLine.substringAfter("#EXTINF:").substringAfter(',').substringAfter(" - ") + + val mockSong = Song( + song = SongEntity( + id = "", + title = title, + ), + artists = artists.map { ArtistEntity("", it) }, + ) + songs.add(mockSong) + + } + } + } + } + } + + if (songs.isEmpty()) { + Toast.makeText( + context, + "No songs found. Invalid file, or perhaps no song matches were found.", + Toast.LENGTH_SHORT + ).show() + } + return songs + } + + fun resetVisitorData(context: Context) { + runCatching { + // Implementa aquí cómo borras VISITOR_DATA, por ejemplo, desde DataStore + val visitorDataFile = context.filesDir / "datastore" / SETTINGS_FILENAME + if (visitorDataFile.exists()) { + // Borra solo la parte de VISITOR_DATA si es posible, o reinicia el archivo + visitorDataFile.delete() + } + + Toast.makeText( + context, + "VISITOR_DATA reseteado. La aplicación se reiniciará.", + Toast.LENGTH_SHORT + ).show() + + context.stopService(Intent(context, MusicService::class.java)) + context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() + context.startActivity( + Intent( + context, + MainActivity::class.java + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + exitProcess(0) + }.onFailure { + reportException(it) + Toast.makeText(context, "Error al resetear VISITOR_DATA", Toast.LENGTH_SHORT).show() + } + } + + companion object { + const val SETTINGS_FILENAME = "settings.preferences_pb" + } +} + diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/ExploreViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/ExploreViewModel.kt new file mode 100644 index 00000000..42b574ac --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/ExploreViewModel.kt @@ -0,0 +1,74 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.filterExplicit +import com.darkxvenom.airbeats.innertube.pages.ExplorePage +import com.darkxvenom.airbeats.constants.HideExplicitKey +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ExploreViewModel +@Inject +constructor( + @ApplicationContext val context: Context, + val database: MusicDatabase, +) : ViewModel() { + val explorePage = MutableStateFlow(null) + + private suspend fun load() { + YouTube + .explore() + .onSuccess { page -> + val artists: MutableMap = mutableMapOf() + val favouriteArtists: MutableMap = mutableMapOf() + database.allArtistsByPlayTime().first().let { list -> + var favIndex = 0 + for ((artistsIndex, artist) in list.withIndex()) { + artists[artistsIndex] = artist.id + if (artist.artist.bookmarkedAt != null) { + favouriteArtists[favIndex] = artist.id + favIndex++ + } + } + } + explorePage.value = + page.copy( + newReleaseAlbums = + page.newReleaseAlbums + .sortedBy { album -> + val artistIds = album.artists.orEmpty().mapNotNull { it.id } + val firstArtistKey = + artistIds.firstNotNullOfOrNull { artistId -> + if (artistId in favouriteArtists.values) { + favouriteArtists.entries.firstOrNull { it.value == artistId }?.key + } else { + artists.entries.firstOrNull { it.value == artistId }?.key + } + } ?: Int.MAX_VALUE + firstArtistKey + }.filterExplicit(context.dataStore.get(HideExplicitKey, false)), + ) + }.onFailure { + reportException(it) + } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + load() + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/HistoryViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/HistoryViewModel.kt new file mode 100644 index 00000000..f22dc845 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/HistoryViewModel.kt @@ -0,0 +1,101 @@ +package com.darkxvenom.airbeats.viewmodels + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.pages.HistoryPage +import com.darkxvenom.airbeats.constants.HistorySource +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +@HiltViewModel +class HistoryViewModel +@Inject +constructor( + val database: MusicDatabase, +) : ViewModel() { + var historySource = MutableStateFlow(HistorySource.LOCAL) + + private val today = LocalDate.now() + private val thisMonday = today.with(DayOfWeek.MONDAY) + private val lastMonday = thisMonday.minusDays(7) + + val historyPage = mutableStateOf(null) + + val events = + database + .events() + .map { events -> + events + .groupBy { + val date = it.event.timestamp.toLocalDate() + val daysAgo = ChronoUnit.DAYS.between(date, today).toInt() + when { + daysAgo == 0 -> DateAgo.Today + daysAgo == 1 -> DateAgo.Yesterday + date >= thisMonday -> DateAgo.ThisWeek + date >= lastMonday -> DateAgo.LastWeek + else -> DateAgo.Other(date.withDayOfMonth(1)) + } + }.toSortedMap( + compareBy { dateAgo -> + when (dateAgo) { + DateAgo.Today -> 0L + DateAgo.Yesterday -> 1L + DateAgo.ThisWeek -> 2L + DateAgo.LastWeek -> 3L + is DateAgo.Other -> ChronoUnit.DAYS.between(dateAgo.date, today) + } + }, + ).mapValues { entry -> + entry.value.distinctBy { it.song.id } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyMap()) + + init { + fetchRemoteHistory() + } + + fun fetchRemoteHistory() { + viewModelScope.launch(Dispatchers.IO) { + YouTube.musicHistory().onSuccess { + historyPage.value = it + }.onFailure { + reportException(it) + } + } + } +} + +sealed class DateAgo { + data object Today : DateAgo() + + data object Yesterday : DateAgo() + + data object ThisWeek : DateAgo() + + data object LastWeek : DateAgo() + + class Other( + val date: LocalDate, + ) : DateAgo() { + override fun equals(other: Any?): Boolean { + if (other is Other) return date == other.date + return super.equals(other) + } + + override fun hashCode(): Int = date.hashCode() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/HomeViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/HomeViewModel.kt new file mode 100644 index 00000000..72ed3425 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/HomeViewModel.kt @@ -0,0 +1,175 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.WatchEndpoint +import com.darkxvenom.airbeats.innertube.models.YTItem +import com.darkxvenom.airbeats.innertube.pages.ExplorePage +import com.darkxvenom.airbeats.innertube.pages.HomePage +import com.darkxvenom.airbeats.innertube.utils.completedLibraryPage +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.db.entities.Artist +import com.darkxvenom.airbeats.db.entities.LocalItem +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.Song +import com.darkxvenom.airbeats.models.SimilarRecommendation +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + @ApplicationContext context: Context, + val database: MusicDatabase, +) : ViewModel() { + val isRefreshing = MutableStateFlow(false) + val isLoading = MutableStateFlow(false) + + val quickPicks = MutableStateFlow?>(null) + val forgottenFavorites = MutableStateFlow?>(null) + val keepListening = MutableStateFlow?>(null) + val similarRecommendations = MutableStateFlow?>(null) + val accountPlaylists = MutableStateFlow?>(null) + val homePage = MutableStateFlow(null) + val explorePage = MutableStateFlow(null) + val recentActivity = MutableStateFlow?>(null) + val recentPlaylistsDb = MutableStateFlow?>(null) + + val allLocalItems = MutableStateFlow>(emptyList()) + val allYtItems = MutableStateFlow>(emptyList()) + + val accountName = MutableStateFlow("Guest") + val accountImageUrl = MutableStateFlow(null) + + private suspend fun load() { + isLoading.value = true + + quickPicks.value = database.quickPicks() + .first().shuffled().take(20) + + forgottenFavorites.value = database.forgottenFavorites() + .first().shuffled().take(20) + + val fromTimeStamp = System.currentTimeMillis() - 86400000 * 7 * 2 + val keepListeningSongs = database.mostPlayedSongs(fromTimeStamp, limit = 15, offset = 5) + .first().shuffled().take(10) + val keepListeningAlbums = database.mostPlayedAlbums(fromTimeStamp, limit = 8, offset = 2) + .first().filter { it.album.thumbnailUrl != null }.shuffled().take(5) + val keepListeningArtists = database.mostPlayedArtists(fromTimeStamp) + .first().filter { it.artist.isYouTubeArtist && it.artist.thumbnailUrl != null } + .shuffled().take(5) + keepListening.value = + (keepListeningSongs + keepListeningAlbums + keepListeningArtists).shuffled() + + allLocalItems.value = + (quickPicks.value.orEmpty() + forgottenFavorites.value.orEmpty() + keepListening.value.orEmpty()) + .filter { it is Song || it is Album } + + if (YouTube.cookie != null) { + YouTube.library("FEmusic_liked_playlists").completedLibraryPage().onSuccess { + accountPlaylists.value = it.items.filterIsInstance() + .filterNot { it.id == "SE" } + }.onFailure { + reportException(it) + } + } + + // Similar to artists + val artistRecommendations = + database.mostPlayedArtists(fromTimeStamp, limit = 10).first() + .filter { it.artist.isYouTubeArtist } + .shuffled().take(3) + .mapNotNull { + val items = mutableListOf() + YouTube.artist(it.id).onSuccess { page -> + items += page.sections.getOrNull(page.sections.size - 2)?.items.orEmpty() + items += page.sections.lastOrNull()?.items.orEmpty() + } + SimilarRecommendation( + title = it, + items = items + .shuffled() + .ifEmpty { return@mapNotNull null } + ) + } + // Similar to songs + val songRecommendations = + database.mostPlayedSongs(fromTimeStamp, limit = 10).first() + .filter { it.album != null } + .shuffled().take(2) + .mapNotNull { song -> + val endpoint = + YouTube.next(WatchEndpoint(videoId = song.id)).getOrNull()?.relatedEndpoint + ?: return@mapNotNull null + val page = YouTube.related(endpoint).getOrNull() ?: return@mapNotNull null + SimilarRecommendation( + title = song, + items = (page.songs.shuffled().take(8) + + page.albums.shuffled().take(4) + + page.artists.shuffled().take(4) + + page.playlists.shuffled().take(4)) + .shuffled() + .ifEmpty { return@mapNotNull null } + ) + } + similarRecommendations.value = (artistRecommendations + songRecommendations).shuffled() + + YouTube.home().onSuccess { page -> + homePage.value = page + }.onFailure { + reportException(it) + } + + YouTube.explore().onSuccess { page -> + val artists: Set + val favouriteArtists: Set + database.artistsBookmarkedByCreateDateAsc().first().let { list -> + artists = list.map(Artist::id).toHashSet() + favouriteArtists = list + .filter { it.artist.bookmarkedAt != null } + .map { it.id } + .toHashSet() + } + explorePage.value = page.copy( + newReleaseAlbums = page.newReleaseAlbums + .sortedBy { album -> + if (album.artists.orEmpty().any { it.id in favouriteArtists }) 0 + else if (album.artists.orEmpty().any { it.id in artists }) 1 + else 2 + } + ) + }.onFailure { + reportException(it) + } + + allYtItems.value = similarRecommendations.value?.flatMap { it.items }.orEmpty() + + homePage.value?.sections?.flatMap { it.items }.orEmpty() + + explorePage.value?.newReleaseAlbums.orEmpty() + + isLoading.value = false + } + + fun refresh() { + if (isRefreshing.value) return + viewModelScope.launch(Dispatchers.IO) { + isRefreshing.value = true + load() + isRefreshing.value = false + } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + load() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LibraryViewModels.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LibraryViewModels.kt new file mode 100644 index 00000000..656ecf31 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LibraryViewModels.kt @@ -0,0 +1,367 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.exoplayer.offline.Download +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.constants.AlbumFilter +import com.darkxvenom.airbeats.constants.AlbumFilterKey +import com.darkxvenom.airbeats.constants.AlbumSortDescendingKey +import com.darkxvenom.airbeats.constants.AlbumSortType +import com.darkxvenom.airbeats.constants.AlbumSortTypeKey +import com.darkxvenom.airbeats.constants.ArtistFilter +import com.darkxvenom.airbeats.constants.ArtistFilterKey +import com.darkxvenom.airbeats.constants.ArtistSongSortDescendingKey +import com.darkxvenom.airbeats.constants.ArtistSongSortType +import com.darkxvenom.airbeats.constants.ArtistSongSortTypeKey +import com.darkxvenom.airbeats.constants.ArtistSortDescendingKey +import com.darkxvenom.airbeats.constants.ArtistSortType +import com.darkxvenom.airbeats.constants.ArtistSortTypeKey +import com.darkxvenom.airbeats.constants.LibraryFilter +import com.darkxvenom.airbeats.constants.PlaylistSortDescendingKey +import com.darkxvenom.airbeats.constants.PlaylistSortType +import com.darkxvenom.airbeats.constants.PlaylistSortTypeKey +import com.darkxvenom.airbeats.constants.SongFilter +import com.darkxvenom.airbeats.constants.SongFilterKey +import com.darkxvenom.airbeats.constants.SongSortDescendingKey +import com.darkxvenom.airbeats.constants.SongSortType +import com.darkxvenom.airbeats.constants.SongSortTypeKey +import com.darkxvenom.airbeats.constants.TopSize +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.extensions.reversed +import com.darkxvenom.airbeats.extensions.toEnum +import com.darkxvenom.airbeats.playback.DownloadUtil +import com.darkxvenom.airbeats.utils.SyncUtils +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.text.Collator +import java.time.Duration +import java.time.LocalDateTime +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class LibrarySongsViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + downloadUtil: DownloadUtil, + private val syncUtils: SyncUtils, +) : ViewModel() { + val allSongs = + context.dataStore.data + .map { + Triple( + it[SongFilterKey].toEnum(SongFilter.LIKED), + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE), + (it[SongSortDescendingKey] ?: true), + ) + }.distinctUntilChanged() + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + SongFilter.LIBRARY -> database.songs(sortType, descending) + SongFilter.LIKED -> database.likedSongs(sortType, descending) + SongFilter.DOWNLOADED -> + downloadUtil.downloads.flatMapLatest { downloads -> + database + .allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + }.map { songs -> + when (sortType) { + SongSortType.CREATE_DATE -> songs.sortedBy { + downloads[it.id]?.updateTimeMs ?: 0L + } + + SongSortType.NAME -> songs.sortedBy { it.song.title } + SongSortType.ARTIST -> { + val collator = + Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + songs + .sortedWith( + compareBy(collator) { song -> + song.artists.joinToString( + "", + ) { it.name } + }, + ).groupBy { it.album?.title } + .flatMap { (_, songsByAlbum) -> + songsByAlbum.sortedBy { album -> + album.artists.joinToString( + "", + ) { it.name } + } + } + } + + SongSortType.PLAY_TIME -> songs.sortedBy { it.song.totalPlayTime } + }.reversed(descending) + } + } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun syncLikedSongs() { + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedSongs() } + } + + fun syncLibrarySongs() { + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLibrarySongs() } + } +} + +@HiltViewModel +class LibraryArtistsViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + private val syncUtils: SyncUtils, +) : ViewModel() { + val allArtists = + context.dataStore.data + .map { + Triple( + it[ArtistFilterKey].toEnum(ArtistFilter.LIKED), + it[ArtistSortTypeKey].toEnum(ArtistSortType.CREATE_DATE), + it[ArtistSortDescendingKey] ?: true, + ) + }.distinctUntilChanged() + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + ArtistFilter.LIBRARY -> database.artists(sortType, descending) + ArtistFilter.LIKED -> database.artistsBookmarked(sortType, descending) + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun sync() { + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncArtistsSubscriptions() } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + allArtists.collect { artists -> + artists + .map { it.artist } + .filter { + it.thumbnailUrl == null || Duration.between( + it.lastUpdateTime, + LocalDateTime.now() + ) > Duration.ofDays(10) + }.forEach { artist -> + YouTube.artist(artist.id).onSuccess { artistPage -> + database.query { + update(artist, artistPage) + } + } + } + } + } + } +} + +@HiltViewModel +class LibraryAlbumsViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + private val syncUtils: SyncUtils, +) : ViewModel() { + val allAlbums = + context.dataStore.data + .map { + Triple( + it[AlbumFilterKey].toEnum(AlbumFilter.LIKED), + it[AlbumSortTypeKey].toEnum(AlbumSortType.CREATE_DATE), + it[AlbumSortDescendingKey] ?: true, + ) + }.distinctUntilChanged() + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + AlbumFilter.LIBRARY -> database.albums(sortType, descending) + AlbumFilter.LIKED -> database.albumsLiked(sortType, descending) + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun sync() { + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedAlbums() } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + allAlbums.collect { albums -> + albums + .filter { + it.album.songCount == 0 + }.forEach { album -> + YouTube + .album(album.id) + .onSuccess { albumPage -> + database.query { + update(album.album, albumPage, album.artists) + } + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + delete(album.album) + } + } + } + } + } + } + } +} + +@HiltViewModel +class LibraryPlaylistsViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + private val syncUtils: SyncUtils, +) : ViewModel() { + val allPlaylists = + context.dataStore.data + .map { + it[PlaylistSortTypeKey].toEnum(PlaylistSortType.CREATE_DATE) to (it[PlaylistSortDescendingKey] + ?: true) + }.distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.playlists(sortType, descending) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + fun sync() { + viewModelScope.launch(Dispatchers.IO) { syncUtils.syncSavedPlaylists() } + } + + val topValue = + context.dataStore.data + .map { it[TopSize] ?: "50" } + .distinctUntilChanged() +} + +@HiltViewModel +class ArtistSongsViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val artistId = savedStateHandle.get("artistId")!! + val artist = + database + .artist(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + val songs = + context.dataStore.data + .map { + it[ArtistSongSortTypeKey].toEnum(ArtistSongSortType.CREATE_DATE) to (it[ArtistSongSortDescendingKey] + ?: true) + }.distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.artistSongs(artistId, sortType, descending) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} + +@HiltViewModel +class LibraryMixViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, +) : ViewModel() { + val topValue = + context.dataStore.data + .map { it[TopSize] ?: "50" } + .distinctUntilChanged() + var artists = + database + .artistsBookmarked( + ArtistSortType.CREATE_DATE, + true, + ).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + var albums = database.albumsLiked(AlbumSortType.CREATE_DATE, true) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + var playlists = database.playlists(PlaylistSortType.CREATE_DATE, true) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + viewModelScope.launch(Dispatchers.IO) { + albums.collect { albums -> + albums + .filter { + it.album.songCount == 0 + }.forEach { album -> + YouTube + .album(album.id) + .onSuccess { albumPage -> + database.query { + update(album.album, albumPage, album.artists) + } + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + delete(album.album) + } + } + } + } + } + } + viewModelScope.launch(Dispatchers.IO) { + artists.collect { artists -> + artists + .map { it.artist } + .filter { + it.thumbnailUrl == null || + Duration.between( + it.lastUpdateTime, + LocalDateTime.now(), + ) > Duration.ofDays(10) + }.forEach { artist -> + YouTube.artist(artist.id).onSuccess { artistPage -> + database.query { + update(artist, artistPage) + } + } + } + } + } + } +} + +@HiltViewModel +class LibraryViewModel +@Inject +constructor() : ViewModel() { + private val curScreen = mutableStateOf(LibraryFilter.LIBRARY) + val filter: MutableState = curScreen +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalPlaylistViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalPlaylistViewModel.kt new file mode 100644 index 00000000..551d28c0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalPlaylistViewModel.kt @@ -0,0 +1,87 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.constants.PlaylistSongSortDescendingKey +import com.darkxvenom.airbeats.constants.PlaylistSongSortType +import com.darkxvenom.airbeats.constants.PlaylistSongSortTypeKey +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.PlaylistSong +import com.darkxvenom.airbeats.extensions.reversed +import com.darkxvenom.airbeats.extensions.toEnum +import com.darkxvenom.airbeats.utils.dataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.text.Collator +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class LocalPlaylistViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val playlistId = savedStateHandle.get("playlistId")!! + val playlist = + database + .playlist(playlistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + val playlistSongs: StateFlow> = + combine( + database.playlistSongs(playlistId), + context.dataStore.data + .map { + it[PlaylistSongSortTypeKey].toEnum(PlaylistSongSortType.CUSTOM) to (it[PlaylistSongSortDescendingKey] + ?: true) + }.distinctUntilChanged(), + ) { songs, (sortType, sortDescending) -> + when (sortType) { + PlaylistSongSortType.CUSTOM -> songs + PlaylistSongSortType.CREATE_DATE -> songs.sortedBy { it.map.id } + PlaylistSongSortType.NAME -> songs.sortedBy { it.song.song.title } + PlaylistSongSortType.ARTIST -> { + val collator = Collator.getInstance(Locale.getDefault()) + collator.strength = Collator.PRIMARY + songs + .sortedWith(compareBy(collator) { song -> song.song.artists.joinToString("") { it.name } }) + .groupBy { it.song.album?.title } + .flatMap { (_, songsByAlbum) -> + songsByAlbum.sortedBy { + it.song.artists.joinToString( + "" + ) { it.name } + } + } + } + + PlaylistSongSortType.PLAY_TIME -> songs.sortedBy { it.song.song.totalPlayTime } + }.reversed(sortDescending && sortType != PlaylistSongSortType.CUSTOM) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + viewModelScope.launch { + val sortedSongs = + playlistSongs.first().sortedWith(compareBy({ it.map.position }, { it.map.id })) + database.transaction { + sortedSongs.forEachIndexed { index, playlistSong -> + if (playlistSong.map.position != index) { + update(playlistSong.map.copy(position = index)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSearchViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSearchViewModel.kt new file mode 100644 index 00000000..721a04e3 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSearchViewModel.kt @@ -0,0 +1,93 @@ +package com.darkxvenom.airbeats.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.Album +import com.darkxvenom.airbeats.db.entities.Artist +import com.darkxvenom.airbeats.db.entities.LocalItem +import com.darkxvenom.airbeats.db.entities.Playlist +import com.darkxvenom.airbeats.db.entities.Song +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class LocalSearchViewModel +@Inject +constructor( + database: MusicDatabase, +) : ViewModel() { + val query = MutableStateFlow("") + val filter = MutableStateFlow(LocalFilter.ALL) + + val result = + combine(query, filter) { query, filter -> + query to filter + }.flatMapLatest { (query, filter) -> + if (query.isEmpty()) { + flowOf(LocalSearchResult("", filter, emptyMap())) + } else { + when (filter) { + LocalFilter.ALL -> + combine( + database.searchSongs(query, PREVIEW_SIZE), + database.searchAlbums(query, PREVIEW_SIZE), + database.searchArtists(query, PREVIEW_SIZE), + database.searchPlaylists(query, PREVIEW_SIZE), + ) { songs, albums, artists, playlists -> + songs + albums + artists + playlists + } + + LocalFilter.SONG -> database.searchSongs(query) + LocalFilter.ALBUM -> database.searchAlbums(query) + LocalFilter.ARTIST -> database.searchArtists(query) + LocalFilter.PLAYLIST -> database.searchPlaylists(query) + }.map { list -> + LocalSearchResult( + query = query, + filter = filter, + map = + list.groupBy { + when (it) { + is Song -> LocalFilter.SONG + is Album -> LocalFilter.ALBUM + is Artist -> LocalFilter.ARTIST + is Playlist -> LocalFilter.PLAYLIST + } + }, + ) + } + } + }.stateIn( + viewModelScope, + SharingStarted.Lazily, + LocalSearchResult("", filter.value, emptyMap()) + ) + + companion object { + const val PREVIEW_SIZE = 3 + } +} + +enum class LocalFilter { + ALL, + SONG, + ALBUM, + ARTIST, + PLAYLIST, +} + +data class LocalSearchResult( + val query: String, + val filter: LocalFilter, + val map: Map>, +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSong.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSong.kt new file mode 100644 index 00000000..b9a17ea2 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSong.kt @@ -0,0 +1,23 @@ +package com.darkxvenom.airbeats.models + +import android.net.Uri + +data class LocalSong( + val id: Long, + val title: String, + val artist: String, + val album: String, + val duration: Long, // milliseconds + val uri: Uri, + val albumArtUri: Uri?, + val size: Long, + val dateAdded: Long, +) { + val durationText: String + get() { + val totalSeconds = duration / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d".format(minutes, seconds) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSongsViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSongsViewModel.kt new file mode 100644 index 00000000..e1635c28 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LocalSongsViewModel.kt @@ -0,0 +1,145 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.app.Application +import android.content.ContentUris +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.models.LocalSong +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LocalSongsViewModel @Inject constructor( + application: Application +) : AndroidViewModel(application) { + + private val _songs = MutableStateFlow>(emptyList()) + val songs: StateFlow> = _songs.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _filteredSongs = MutableStateFlow>(emptyList()) + val filteredSongs: StateFlow> = _filteredSongs.asStateFlow() + + private val contentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + loadSongs() + } + } + + init { + application.contentResolver.registerContentObserver( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + true, + contentObserver + ) + } + + fun loadSongs() { + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + val context = getApplication() + val songList = mutableListOf() + + val collection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + + val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ALBUM, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.SIZE, + MediaStore.Audio.Media.DATE_ADDED, + MediaStore.Audio.Media.ALBUM_ID, + ) + + val selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0 AND ${MediaStore.Audio.Media.DURATION} >= 30000" + val sortOrder = "${MediaStore.Audio.Media.TITLE} ASC" + + try { + context.contentResolver.query( + collection, + projection, + selection, + null, + sortOrder + )?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) + val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) + val dateCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED) + val albumIdCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idCol) + val albumId = cursor.getLong(albumIdCol) + val contentUri = ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id + ) + val albumArtUri = ContentUris.withAppendedId( + Uri.parse("content://media/external/audio/albumart"), albumId + ) + + songList += LocalSong( + id = id, + title = cursor.getString(titleCol) ?: "Unknown Title", + artist = cursor.getString(artistCol) ?: "Unknown Artist", + album = cursor.getString(albumCol) ?: "Unknown Album", + duration = cursor.getLong(durationCol), + uri = contentUri, + albumArtUri = albumArtUri, + size = cursor.getLong(sizeCol), + dateAdded = cursor.getLong(dateCol), + ) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + + _songs.value = songList + applyFilter(songList, _searchQuery.value) + _isLoading.value = false + } + } + + fun onSearchQueryChange(query: String) { + _searchQuery.value = query + applyFilter(_songs.value, query) + } + + private fun applyFilter(songs: List, query: String) { + _filteredSongs.value = if (query.isBlank()) { + songs + } else { + songs.filter { song -> + song.title.contains(query, ignoreCase = true) || + song.artist.contains(query, ignoreCase = true) || + song.album.contains(query, ignoreCase = true) + } + } + } + + override fun onCleared() { + super.onCleared() + getApplication().contentResolver.unregisterContentObserver(contentObserver) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LyricsMenuViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LyricsMenuViewModel.kt new file mode 100644 index 00000000..68a08d76 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/LyricsMenuViewModel.kt @@ -0,0 +1,68 @@ +package com.darkxvenom.airbeats.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.LyricsEntity +import com.darkxvenom.airbeats.lyrics.LyricsHelper +import com.darkxvenom.airbeats.lyrics.LyricsResult +import com.darkxvenom.airbeats.models.MediaMetadata +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +@HiltViewModel +class LyricsMenuViewModel +@Inject +constructor( + private val lyricsHelper: LyricsHelper, + val database: MusicDatabase, +) : ViewModel() { + private var job: Job? = null + val results = MutableStateFlow(emptyList()) + val isLoading = MutableStateFlow(false) + + fun search( + mediaId: String, + title: String, + artist: String, + duration: Int, + ) { + isLoading.value = true + results.value = emptyList() + job?.cancel() + job = + viewModelScope.launch(Dispatchers.IO) { + lyricsHelper.getAllLyrics(mediaId, title, artist, duration) { result -> + results.update { + it + result + } + } + isLoading.value = false + } + } + + fun cancelSearch() { + job?.cancel() + job = null + } + + fun refetchLyrics( + mediaMetadata: MediaMetadata, + lyricsEntity: LyricsEntity?, + ) { + database.query { + lyricsEntity?.let(::delete) + val lyrics = + runBlocking { + lyricsHelper.getLyrics(mediaMetadata) + } + upsert(LyricsEntity(mediaMetadata.id, lyrics)) + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/MoodAndGenresViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/MoodAndGenresViewModel.kt new file mode 100644 index 00000000..a0276955 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/MoodAndGenresViewModel.kt @@ -0,0 +1,30 @@ +package com.darkxvenom.airbeats.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.pages.MoodAndGenres +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MoodAndGenresViewModel +@Inject +constructor() : ViewModel() { + val moodAndGenres = MutableStateFlow?>(null) + + init { + viewModelScope.launch { + YouTube + .moodAndGenres() + .onSuccess { + moodAndGenres.value = it + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/NewReleaseViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/NewReleaseViewModel.kt new file mode 100644 index 00000000..1701777e --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/NewReleaseViewModel.kt @@ -0,0 +1,119 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.AlbumItem +import com.darkxvenom.airbeats.innertube.models.filterExplicit +import com.darkxvenom.airbeats.constants.HideExplicitKey +import com.darkxvenom.airbeats.constants.LastNewReleaseCheckKey +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class NewReleaseViewModel +@Inject +constructor( + @ApplicationContext val context: Context, + database: MusicDatabase, +) : ViewModel() { + private val _newReleaseAlbums = MutableStateFlow>(emptyList()) + val newReleaseAlbums = _newReleaseAlbums.asStateFlow() + + private val _hasNewReleases = MutableStateFlow(false) + val hasNewReleases = _hasNewReleases.asStateFlow() + + init { + viewModelScope.launch { + YouTube + .newReleaseAlbums() + .onSuccess { albums -> + val artists: MutableMap = mutableMapOf() + val favouriteArtists: MutableMap = mutableMapOf() + database.allArtistsByPlayTime().first().let { list -> + var favIndex = 0 + for ((artistsIndex, artist) in list.withIndex()) { + artists[artistsIndex] = artist.id + if (artist.artist.bookmarkedAt != null) { + favouriteArtists[favIndex] = artist.id + favIndex++ + } + } + } + + val sortedAlbums = albums + .sortedBy { album -> + val artistIds = album.artists.orEmpty().mapNotNull { it.id } + val firstArtistKey = + artistIds.firstNotNullOfOrNull { artistId -> + if (artistId in favouriteArtists.values) { + favouriteArtists.entries.firstOrNull { it.value == artistId }?.key + } else { + artists.entries.firstOrNull { it.value == artistId }?.key + } + } ?: Int.MAX_VALUE + firstArtistKey + }.filterExplicit(context.dataStore.get(HideExplicitKey, false)) + + _newReleaseAlbums.value = sortedAlbums + + // Verificar si hay nuevos lanzamientos + checkForNewReleases() + + }.onFailure { + reportException(it) + } + } + } + + + + private suspend fun checkForNewReleases() { + try { + val lastCheckTime = context.dataStore.get(LastNewReleaseCheckKey, 0L) + val currentTime = System.currentTimeMillis() + + // Si es la primera vez que se verifica, no mostrar notificación + if (lastCheckTime == 0L) { + context.dataStore.updateData { it.toMutablePreferences().apply { + set(LastNewReleaseCheckKey, currentTime) + }} + _hasNewReleases.value = false + return + } + + // Si hay álbumes y ha pasado suficiente tiempo desde la última verificación + val hasNewReleases = _newReleaseAlbums.value.isNotEmpty() && + (currentTime - lastCheckTime) > (24 * 60 * 60 * 1000) // 24 horas + + _hasNewReleases.value = hasNewReleases + + } catch (e: Exception) { + reportException(e) + _hasNewReleases.value = false + } + } + + fun markNewReleasesAsSeen() { + viewModelScope.launch { + try { + context.dataStore.updateData { it.toMutablePreferences().apply { + set(LastNewReleaseCheckKey, System.currentTimeMillis()) + }} + _hasNewReleases.value = false + } catch (e: Exception) { + reportException(e) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlinePlaylistViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlinePlaylistViewModel.kt new file mode 100644 index 00000000..78ad3528 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlinePlaylistViewModel.kt @@ -0,0 +1,47 @@ +package com.darkxvenom.airbeats.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.PlaylistItem +import com.darkxvenom.airbeats.innertube.models.SongItem +import com.darkxvenom.airbeats.innertube.utils.completed +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnlinePlaylistViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + database: MusicDatabase +) : ViewModel() { + private val playlistId = savedStateHandle.get("playlistId")!! + + val playlist = MutableStateFlow(null) + val playlistSongs = MutableStateFlow>(emptyList()) + val dbPlaylist = database.playlistByBrowseId(playlistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch(Dispatchers.IO) { + YouTube.playlist(playlistId).completed() + .onSuccess { playlistPage -> + playlist.value = playlistPage.playlist + playlistSongs.value = playlistPage.songs + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlineSearchSuggestionViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlineSearchSuggestionViewModel.kt new file mode 100644 index 00000000..45664e95 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlineSearchSuggestionViewModel.kt @@ -0,0 +1,83 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.YTItem +import com.darkxvenom.airbeats.innertube.models.filterExplicit +import com.darkxvenom.airbeats.constants.HideExplicitKey +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.db.entities.SearchHistory +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class OnlineSearchSuggestionViewModel +@Inject +constructor( + @ApplicationContext val context: Context, + database: MusicDatabase, +) : ViewModel() { + val query = MutableStateFlow("") + private val _viewState = MutableStateFlow(SearchSuggestionViewState()) + val viewState = _viewState.asStateFlow() + + init { + viewModelScope.launch { + query + .flatMapLatest { query -> + if (query.isEmpty()) { + database.searchHistory().map { history -> + SearchSuggestionViewState( + history = history, + ) + } + } else { + val result = YouTube.searchSuggestions(query).getOrNull() + database + .searchHistory(query) + .map { it.take(3) } + .map { history -> + SearchSuggestionViewState( + history = history, + suggestions = + result + ?.queries + ?.filter { query -> + history.none { it.query == query } + }.orEmpty(), + items = + result + ?.recommendedItems + ?.filterExplicit( + context.dataStore.get( + HideExplicitKey, + false, + ), + ).orEmpty(), + ) + } + } + }.collect { + _viewState.value = it + } + } + } +} + +data class SearchSuggestionViewState( + val history: List = emptyList(), + val suggestions: List = emptyList(), + val items: List = emptyList(), +) diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlineSearchViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlineSearchViewModel.kt new file mode 100644 index 00000000..b4e378b0 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/OnlineSearchViewModel.kt @@ -0,0 +1,98 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.models.filterExplicit +import com.darkxvenom.airbeats.innertube.pages.SearchSummaryPage +import com.darkxvenom.airbeats.constants.HideExplicitKey +import com.darkxvenom.airbeats.models.ItemsPage +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnlineSearchViewModel +@Inject +constructor( + @ApplicationContext val context: Context, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val query = savedStateHandle.get("query")!! + val filter = MutableStateFlow(null) + var summaryPage by mutableStateOf(null) + val viewStateMap = mutableStateMapOf() + + init { + viewModelScope.launch { + filter.collect { filter -> + if (filter == null) { + if (summaryPage == null) { + YouTube + .searchSummary(query) + .onSuccess { + summaryPage = + it.filterExplicit( + context.dataStore.get( + HideExplicitKey, + false, + ), + ) + }.onFailure { + reportException(it) + } + } + } else { + if (viewStateMap[filter.value] == null) { + YouTube + .search(query, filter) + .onSuccess { result -> + viewStateMap[filter.value] = + ItemsPage( + result.items + .distinctBy { it.id } + .filterExplicit( + context.dataStore.get( + HideExplicitKey, + false + ) + ), + result.continuation, + ) + }.onFailure { + reportException(it) + } + } + } + } + } + } + + fun loadMore() { + val filter = filter.value?.value + viewModelScope.launch { + if (filter == null) return@launch + val viewState = viewStateMap[filter] ?: return@launch + val continuation = viewState.continuation + if (continuation != null) { + val searchResult = + YouTube.searchContinuation(continuation).getOrNull() ?: return@launch + viewStateMap[filter] = ItemsPage( + (viewState.items + searchResult.items).distinctBy { it.id }, + searchResult.continuation + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/StatsViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/StatsViewModel.kt new file mode 100644 index 00000000..19a8d409 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/StatsViewModel.kt @@ -0,0 +1,175 @@ +package com.darkxvenom.airbeats.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.constants.statToPeriod +import com.darkxvenom.airbeats.db.MusicDatabase +import com.darkxvenom.airbeats.ui.screens.OptionStats +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneOffset +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class StatsViewModel +@Inject +constructor( + val database: MusicDatabase, +) : ViewModel() { + val selectedOption = MutableStateFlow(OptionStats.CONTINUOUS) + val indexChips = MutableStateFlow(0) + + val mostPlayedSongsStats = + combine( + selectedOption, + indexChips, + ) { first, second -> Pair(first, second) } + .flatMapLatest { (selection, t) -> + database + .mostPlayedSongsStats( + fromTimeStamp = statToPeriod(selection, t), + limit = -1, + toTimeStamp = + if (selection == OptionStats.CONTINUOUS || t == 0) { + LocalDateTime + .now() + .toInstant( + ZoneOffset.UTC, + ).toEpochMilli() + } else { + statToPeriod(selection, t - 1) + }, + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val mostPlayedSongs = + combine( + selectedOption, + indexChips, + ) { first, second -> Pair(first, second) } + .flatMapLatest { (selection, t) -> + database + .mostPlayedSongs( + fromTimeStamp = statToPeriod(selection, t), + limit = -1, + toTimeStamp = + if (selection == OptionStats.CONTINUOUS || t == 0) { + LocalDateTime + .now() + .toInstant( + ZoneOffset.UTC, + ).toEpochMilli() + } else { + statToPeriod(selection, t - 1) + }, + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val mostPlayedArtists = + combine( + selectedOption, + indexChips, + ) { first, second -> Pair(first, second) } + .flatMapLatest { (selection, t) -> + database + .mostPlayedArtists( + statToPeriod(selection, t), + limit = -1, + toTimeStamp = + if (selection == OptionStats.CONTINUOUS || t == 0) { + LocalDateTime + .now() + .toInstant( + ZoneOffset.UTC, + ).toEpochMilli() + } else { + statToPeriod(selection, t - 1) + }, + ).map { artists -> + artists.filter { it.artist.isYouTubeArtist } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val mostPlayedAlbums = + combine( + selectedOption, + indexChips, + ) { first, second -> Pair(first, second) } + .flatMapLatest { (selection, t) -> + database.mostPlayedAlbums( + statToPeriod(selection, t), + limit = -1, + toTimeStamp = + if (selection == OptionStats.CONTINUOUS || t == 0) { + LocalDateTime + .now() + .toInstant( + ZoneOffset.UTC, + ).toEpochMilli() + } else { + statToPeriod(selection, t - 1) + }, + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val firstEvent = + database + .firstEvent() + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + init { + viewModelScope.launch { + mostPlayedArtists.collect { artists -> + artists + .map { it.artist } + .filter { + it.thumbnailUrl == null || Duration.between( + it.lastUpdateTime, + LocalDateTime.now() + ) > Duration.ofDays(10) + }.forEach { artist -> + YouTube.artist(artist.id).onSuccess { artistPage -> + database.query { + update(artist, artistPage) + } + } + } + } + } + viewModelScope.launch { + mostPlayedAlbums.collect { albums -> + albums + .filter { + it.album.songCount == 0 + }.forEach { album -> + YouTube + .album(album.id) + .onSuccess { albumPage -> + database.query { + update(album.album, albumPage, album.artists) + } + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + delete(album.album) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/TopPlaylistViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/TopPlaylistViewModel.kt new file mode 100644 index 00000000..379caaa8 --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/TopPlaylistViewModel.kt @@ -0,0 +1,36 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.constants.MyTopFilter +import com.darkxvenom.airbeats.db.MusicDatabase +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class TopPlaylistViewModel +@Inject +constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val top = savedStateHandle.get("top")!! + + val topPeriod = MutableStateFlow(MyTopFilter.ALL_TIME) + + @OptIn(ExperimentalCoroutinesApi::class) + val topSongs = + topPeriod + .flatMapLatest { period -> + database.mostPlayedSongs(period.toTimeMillis(), top.toInt()) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} diff --git a/app/src/main/java/com/darkxvenom/airbeats/viewmodels/YouTubeBrowseViewModel.kt b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/YouTubeBrowseViewModel.kt new file mode 100644 index 00000000..d43537cb --- /dev/null +++ b/app/src/main/java/com/darkxvenom/airbeats/viewmodels/YouTubeBrowseViewModel.kt @@ -0,0 +1,42 @@ +package com.darkxvenom.airbeats.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.darkxvenom.airbeats.innertube.YouTube +import com.darkxvenom.airbeats.innertube.pages.BrowseResult +import com.darkxvenom.airbeats.constants.HideExplicitKey +import com.darkxvenom.airbeats.utils.dataStore +import com.darkxvenom.airbeats.utils.get +import com.darkxvenom.airbeats.utils.reportException +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class YouTubeBrowseViewModel +@Inject +constructor( + @ApplicationContext val context: Context, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val browseId = savedStateHandle.get("browseId")!! + private val params = savedStateHandle.get("params") + + val result = MutableStateFlow(null) + + init { + viewModelScope.launch { + YouTube + .browse(browseId, params) + .onSuccess { + result.value = it.filterExplicit(context.dataStore.get(HideExplicitKey, false)) + }.onFailure { + reportException(it) + } + } + } +} diff --git a/app/src/main/logoxd-playstore.png b/app/src/main/logoxd-playstore.png new file mode 100644 index 0000000000000000000000000000000000000000..6d1c9c2bf501c88ce5a82d2c9715f85705d42175 GIT binary patch literal 18530 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 eMMG|WN@iLmZVgktK6n8&FnGH9xvX + + diff --git a/app/src/main/res/drawable/add.xml b/app/src/main/res/drawable/add.xml new file mode 100644 index 00000000..a0324307 --- /dev/null +++ b/app/src/main/res/drawable/add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/airbeats.xml b/app/src/main/res/drawable/airbeats.xml new file mode 100644 index 00000000..ca2a37d8 --- /dev/null +++ b/app/src/main/res/drawable/airbeats.xml @@ -0,0 +1,1029 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/airbeats_logo.xml b/app/src/main/res/drawable/airbeats_logo.xml new file mode 100644 index 00000000..023f3647 --- /dev/null +++ b/app/src/main/res/drawable/airbeats_logo.xml @@ -0,0 +1,16157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/airbeats_monochrome.xml b/app/src/main/res/drawable/airbeats_monochrome.xml new file mode 100644 index 00000000..ca2a37d8 --- /dev/null +++ b/app/src/main/res/drawable/airbeats_monochrome.xml @@ -0,0 +1,1029 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/album.xml b/app/src/main/res/drawable/album.xml new file mode 100644 index 00000000..b3591800 --- /dev/null +++ b/app/src/main/res/drawable/album.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/apps.xml b/app/src/main/res/drawable/apps.xml new file mode 100644 index 00000000..8c1bd92d --- /dev/null +++ b/app/src/main/res/drawable/apps.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/arrow_back.xml b/app/src/main/res/drawable/arrow_back.xml new file mode 100644 index 00000000..fa1cbf49 --- /dev/null +++ b/app/src/main/res/drawable/arrow_back.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/arrow_downward.xml b/app/src/main/res/drawable/arrow_downward.xml new file mode 100644 index 00000000..7442d52d --- /dev/null +++ b/app/src/main/res/drawable/arrow_downward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/arrow_forward.xml b/app/src/main/res/drawable/arrow_forward.xml new file mode 100644 index 00000000..343f8af7 --- /dev/null +++ b/app/src/main/res/drawable/arrow_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/arrow_top_left.xml b/app/src/main/res/drawable/arrow_top_left.xml new file mode 100644 index 00000000..2f3d9638 --- /dev/null +++ b/app/src/main/res/drawable/arrow_top_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/arrow_upward.xml b/app/src/main/res/drawable/arrow_upward.xml new file mode 100644 index 00000000..8f271e43 --- /dev/null +++ b/app/src/main/res/drawable/arrow_upward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/artist.xml b/app/src/main/res/drawable/artist.xml new file mode 100644 index 00000000..5bcd9892 --- /dev/null +++ b/app/src/main/res/drawable/artist.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/attach_money.xml b/app/src/main/res/drawable/attach_money.xml new file mode 100644 index 00000000..c9ea8243 --- /dev/null +++ b/app/src/main/res/drawable/attach_money.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/auto_awesome.xml b/app/src/main/res/drawable/auto_awesome.xml new file mode 100644 index 00000000..81240373 --- /dev/null +++ b/app/src/main/res/drawable/auto_awesome.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/backup.xml b/app/src/main/res/drawable/backup.xml new file mode 100644 index 00000000..4a459103 --- /dev/null +++ b/app/src/main/res/drawable/backup.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bedtime.xml b/app/src/main/res/drawable/bedtime.xml new file mode 100644 index 00000000..285da51d --- /dev/null +++ b/app/src/main/res/drawable/bedtime.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/block.xml b/app/src/main/res/drawable/block.xml new file mode 100644 index 00000000..c02366e5 --- /dev/null +++ b/app/src/main/res/drawable/block.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bookmark.xml b/app/src/main/res/drawable/bookmark.xml new file mode 100644 index 00000000..49d25025 --- /dev/null +++ b/app/src/main/res/drawable/bookmark.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bookmark_filled.xml b/app/src/main/res/drawable/bookmark_filled.xml new file mode 100644 index 00000000..c53c19d3 --- /dev/null +++ b/app/src/main/res/drawable/bookmark_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bug_report.xml b/app/src/main/res/drawable/bug_report.xml new file mode 100644 index 00000000..7f07b82d --- /dev/null +++ b/app/src/main/res/drawable/bug_report.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/cached.xml b/app/src/main/res/drawable/cached.xml new file mode 100644 index 00000000..576bd5c6 --- /dev/null +++ b/app/src/main/res/drawable/cached.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/car.xml b/app/src/main/res/drawable/car.xml new file mode 100644 index 00000000..6c46743a --- /dev/null +++ b/app/src/main/res/drawable/car.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/casino.xml b/app/src/main/res/drawable/casino.xml new file mode 100644 index 00000000..c808f870 --- /dev/null +++ b/app/src/main/res/drawable/casino.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/check.xml b/app/src/main/res/drawable/check.xml new file mode 100644 index 00000000..0b9bb321 --- /dev/null +++ b/app/src/main/res/drawable/check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/check_box.xml b/app/src/main/res/drawable/check_box.xml new file mode 100644 index 00000000..4f41440c --- /dev/null +++ b/app/src/main/res/drawable/check_box.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/check_circle.xml b/app/src/main/res/drawable/check_circle.xml new file mode 100644 index 00000000..20ce30a6 --- /dev/null +++ b/app/src/main/res/drawable/check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/chevron_right.xml b/app/src/main/res/drawable/chevron_right.xml new file mode 100644 index 00000000..8df14aef --- /dev/null +++ b/app/src/main/res/drawable/chevron_right.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/clear_all.xml b/app/src/main/res/drawable/clear_all.xml new file mode 100644 index 00000000..4e3ff8c8 --- /dev/null +++ b/app/src/main/res/drawable/clear_all.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/close.xml b/app/src/main/res/drawable/close.xml new file mode 100644 index 00000000..701dab50 --- /dev/null +++ b/app/src/main/res/drawable/close.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/cloud_download.xml b/app/src/main/res/drawable/cloud_download.xml new file mode 100644 index 00000000..ecfffb2c --- /dev/null +++ b/app/src/main/res/drawable/cloud_download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/cloud_lock.xml b/app/src/main/res/drawable/cloud_lock.xml new file mode 100644 index 00000000..42cf57ef --- /dev/null +++ b/app/src/main/res/drawable/cloud_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/codigo.xml b/app/src/main/res/drawable/codigo.xml new file mode 100644 index 00000000..d3659f38 --- /dev/null +++ b/app/src/main/res/drawable/codigo.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/compare_arrows.xml b/app/src/main/res/drawable/compare_arrows.xml new file mode 100644 index 00000000..dec6bfb2 --- /dev/null +++ b/app/src/main/res/drawable/compare_arrows.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/content_copy.xml b/app/src/main/res/drawable/content_copy.xml new file mode 100644 index 00000000..1c357e13 --- /dev/null +++ b/app/src/main/res/drawable/content_copy.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/contrast.xml b/app/src/main/res/drawable/contrast.xml new file mode 100644 index 00000000..3d2923e1 --- /dev/null +++ b/app/src/main/res/drawable/contrast.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/dark_mode.xml b/app/src/main/res/drawable/dark_mode.xml new file mode 100644 index 00000000..b030251c --- /dev/null +++ b/app/src/main/res/drawable/dark_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/delete.xml b/app/src/main/res/drawable/delete.xml new file mode 100644 index 00000000..3e41502c --- /dev/null +++ b/app/src/main/res/drawable/delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/delete_history.xml b/app/src/main/res/drawable/delete_history.xml new file mode 100644 index 00000000..13e05f3b --- /dev/null +++ b/app/src/main/res/drawable/delete_history.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/deployed_code_update.xml b/app/src/main/res/drawable/deployed_code_update.xml new file mode 100644 index 00000000..b312fad3 --- /dev/null +++ b/app/src/main/res/drawable/deployed_code_update.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/deselect.xml b/app/src/main/res/drawable/deselect.xml new file mode 100644 index 00000000..0fac764e --- /dev/null +++ b/app/src/main/res/drawable/deselect.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/desktop_windows.xml b/app/src/main/res/drawable/desktop_windows.xml new file mode 100644 index 00000000..0dbf4e3e --- /dev/null +++ b/app/src/main/res/drawable/desktop_windows.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/discord.xml b/app/src/main/res/drawable/discord.xml new file mode 100644 index 00000000..c37b6f74 --- /dev/null +++ b/app/src/main/res/drawable/discord.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/discover_tune.xml b/app/src/main/res/drawable/discover_tune.xml new file mode 100644 index 00000000..d1b697c4 --- /dev/null +++ b/app/src/main/res/drawable/discover_tune.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/donate.xml b/app/src/main/res/drawable/donate.xml new file mode 100644 index 00000000..5385e48d --- /dev/null +++ b/app/src/main/res/drawable/donate.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/done.xml b/app/src/main/res/drawable/done.xml new file mode 100644 index 00000000..e54f6657 --- /dev/null +++ b/app/src/main/res/drawable/done.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/double_arrow.xml b/app/src/main/res/drawable/double_arrow.xml new file mode 100644 index 00000000..b7b99d35 --- /dev/null +++ b/app/src/main/res/drawable/double_arrow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml new file mode 100644 index 00000000..c743b082 --- /dev/null +++ b/app/src/main/res/drawable/download.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/drag_handle.xml b/app/src/main/res/drawable/drag_handle.xml new file mode 100644 index 00000000..388f908e --- /dev/null +++ b/app/src/main/res/drawable/drag_handle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/edit.xml b/app/src/main/res/drawable/edit.xml new file mode 100644 index 00000000..919ba47c --- /dev/null +++ b/app/src/main/res/drawable/edit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/email.xml b/app/src/main/res/drawable/email.xml new file mode 100644 index 00000000..f78543d2 --- /dev/null +++ b/app/src/main/res/drawable/email.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/empty_splash.xml b/app/src/main/res/drawable/empty_splash.xml new file mode 100644 index 00000000..e5565510 --- /dev/null +++ b/app/src/main/res/drawable/empty_splash.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/empty_state.xml b/app/src/main/res/drawable/empty_state.xml new file mode 100644 index 00000000..6c850d21 --- /dev/null +++ b/app/src/main/res/drawable/empty_state.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/equalizer.xml b/app/src/main/res/drawable/equalizer.xml new file mode 100644 index 00000000..2533b0d2 --- /dev/null +++ b/app/src/main/res/drawable/equalizer.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/error.xml b/app/src/main/res/drawable/error.xml new file mode 100644 index 00000000..fa717de7 --- /dev/null +++ b/app/src/main/res/drawable/error.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/expand_less.xml b/app/src/main/res/drawable/expand_less.xml new file mode 100644 index 00000000..bd907947 --- /dev/null +++ b/app/src/main/res/drawable/expand_less.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/expand_more.xml b/app/src/main/res/drawable/expand_more.xml new file mode 100644 index 00000000..7678e90f --- /dev/null +++ b/app/src/main/res/drawable/expand_more.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/explicit.xml b/app/src/main/res/drawable/explicit.xml new file mode 100644 index 00000000..e26e0216 --- /dev/null +++ b/app/src/main/res/drawable/explicit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/explore.xml b/app/src/main/res/drawable/explore.xml new file mode 100644 index 00000000..d37820cd --- /dev/null +++ b/app/src/main/res/drawable/explore.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/explore_filled.xml b/app/src/main/res/drawable/explore_filled.xml new file mode 100644 index 00000000..5e4b0ded --- /dev/null +++ b/app/src/main/res/drawable/explore_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/explore_outlined.xml b/app/src/main/res/drawable/explore_outlined.xml new file mode 100644 index 00000000..187fef47 --- /dev/null +++ b/app/src/main/res/drawable/explore_outlined.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/extension.xml b/app/src/main/res/drawable/extension.xml new file mode 100644 index 00000000..49f9f6dd --- /dev/null +++ b/app/src/main/res/drawable/extension.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/facebook.xml b/app/src/main/res/drawable/facebook.xml new file mode 100644 index 00000000..ec7ec5e8 --- /dev/null +++ b/app/src/main/res/drawable/facebook.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/fast_forward.xml b/app/src/main/res/drawable/fast_forward.xml new file mode 100644 index 00000000..71fb4547 --- /dev/null +++ b/app/src/main/res/drawable/fast_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/favorite.xml b/app/src/main/res/drawable/favorite.xml new file mode 100644 index 00000000..ac89942d --- /dev/null +++ b/app/src/main/res/drawable/favorite.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/favorite_border.xml b/app/src/main/res/drawable/favorite_border.xml new file mode 100644 index 00000000..f9a3674d --- /dev/null +++ b/app/src/main/res/drawable/favorite_border.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/feedback.xml b/app/src/main/res/drawable/feedback.xml new file mode 100644 index 00000000..e1ff0cc2 --- /dev/null +++ b/app/src/main/res/drawable/feedback.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/folder.xml b/app/src/main/res/drawable/folder.xml new file mode 100644 index 00000000..efab40ff --- /dev/null +++ b/app/src/main/res/drawable/folder.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/format_align_center.xml b/app/src/main/res/drawable/format_align_center.xml new file mode 100644 index 00000000..01672836 --- /dev/null +++ b/app/src/main/res/drawable/format_align_center.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/format_align_left.xml b/app/src/main/res/drawable/format_align_left.xml new file mode 100644 index 00000000..7f8a2d01 --- /dev/null +++ b/app/src/main/res/drawable/format_align_left.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/format_color.xml b/app/src/main/res/drawable/format_color.xml new file mode 100644 index 00000000..21362fe9 --- /dev/null +++ b/app/src/main/res/drawable/format_color.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/fullscreen.xml b/app/src/main/res/drawable/fullscreen.xml new file mode 100644 index 00000000..efab40ff --- /dev/null +++ b/app/src/main/res/drawable/fullscreen.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/fullscreen_exit.xml b/app/src/main/res/drawable/fullscreen_exit.xml new file mode 100644 index 00000000..29723978 --- /dev/null +++ b/app/src/main/res/drawable/fullscreen_exit.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/funbeta.xml b/app/src/main/res/drawable/funbeta.xml new file mode 100644 index 00000000..167e499b --- /dev/null +++ b/app/src/main/res/drawable/funbeta.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/github.xml b/app/src/main/res/drawable/github.xml new file mode 100644 index 00000000..bf36f93e --- /dev/null +++ b/app/src/main/res/drawable/github.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/google.xml b/app/src/main/res/drawable/google.xml new file mode 100644 index 00000000..92cca392 --- /dev/null +++ b/app/src/main/res/drawable/google.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/gradient.xml b/app/src/main/res/drawable/gradient.xml new file mode 100644 index 00000000..bad0906d --- /dev/null +++ b/app/src/main/res/drawable/gradient.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/graphic_eq.xml b/app/src/main/res/drawable/graphic_eq.xml new file mode 100644 index 00000000..a836b4b5 --- /dev/null +++ b/app/src/main/res/drawable/graphic_eq.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/grid_view.xml b/app/src/main/res/drawable/grid_view.xml new file mode 100644 index 00000000..1f5809a8 --- /dev/null +++ b/app/src/main/res/drawable/grid_view.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/group.xml b/app/src/main/res/drawable/group.xml new file mode 100644 index 00000000..a48c0f8b --- /dev/null +++ b/app/src/main/res/drawable/group.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/help.xml b/app/src/main/res/drawable/help.xml new file mode 100644 index 00000000..c72ff8a7 --- /dev/null +++ b/app/src/main/res/drawable/help.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/history.xml b/app/src/main/res/drawable/history.xml new file mode 100644 index 00000000..885f23b5 --- /dev/null +++ b/app/src/main/res/drawable/history.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/home.xml b/app/src/main/res/drawable/home.xml new file mode 100644 index 00000000..c3a803b3 --- /dev/null +++ b/app/src/main/res/drawable/home.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/home_filled.xml b/app/src/main/res/drawable/home_filled.xml new file mode 100644 index 00000000..d653ceb7 --- /dev/null +++ b/app/src/main/res/drawable/home_filled.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/home_outlined.xml b/app/src/main/res/drawable/home_outlined.xml new file mode 100644 index 00000000..074d8c6f --- /dev/null +++ b/app/src/main/res/drawable/home_outlined.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ia_icon.xml b/app/src/main/res/drawable/ia_icon.xml new file mode 100644 index 00000000..f63a3fe7 --- /dev/null +++ b/app/src/main/res/drawable/ia_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/image.xml b/app/src/main/res/drawable/image.xml new file mode 100644 index 00000000..2c947aaf --- /dev/null +++ b/app/src/main/res/drawable/image.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/info.xml b/app/src/main/res/drawable/info.xml new file mode 100644 index 00000000..b6ebd5c7 --- /dev/null +++ b/app/src/main/res/drawable/info.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/input.xml b/app/src/main/res/drawable/input.xml new file mode 100644 index 00000000..a695769c --- /dev/null +++ b/app/src/main/res/drawable/input.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/instagram.xml b/app/src/main/res/drawable/instagram.xml new file mode 100644 index 00000000..dd924a8f --- /dev/null +++ b/app/src/main/res/drawable/instagram.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/language.xml b/app/src/main/res/drawable/language.xml new file mode 100644 index 00000000..dff42785 --- /dev/null +++ b/app/src/main/res/drawable/language.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/liberapay.xml b/app/src/main/res/drawable/liberapay.xml new file mode 100644 index 00000000..d404e4c3 --- /dev/null +++ b/app/src/main/res/drawable/liberapay.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/library_add.xml b/app/src/main/res/drawable/library_add.xml new file mode 100644 index 00000000..3f21006c --- /dev/null +++ b/app/src/main/res/drawable/library_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/library_add_check.xml b/app/src/main/res/drawable/library_add_check.xml new file mode 100644 index 00000000..89c34350 --- /dev/null +++ b/app/src/main/res/drawable/library_add_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/library_music.xml b/app/src/main/res/drawable/library_music.xml new file mode 100644 index 00000000..66e47e72 --- /dev/null +++ b/app/src/main/res/drawable/library_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/library_music_filled.xml b/app/src/main/res/drawable/library_music_filled.xml new file mode 100644 index 00000000..036cdf1f --- /dev/null +++ b/app/src/main/res/drawable/library_music_filled.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/library_music_outlined.xml b/app/src/main/res/drawable/library_music_outlined.xml new file mode 100644 index 00000000..2332326c --- /dev/null +++ b/app/src/main/res/drawable/library_music_outlined.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/license.xml b/app/src/main/res/drawable/license.xml new file mode 100644 index 00000000..e8794654 --- /dev/null +++ b/app/src/main/res/drawable/license.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lightbulb.xml b/app/src/main/res/drawable/lightbulb.xml new file mode 100644 index 00000000..f7c2d9e3 --- /dev/null +++ b/app/src/main/res/drawable/lightbulb.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/line_curve.xml b/app/src/main/res/drawable/line_curve.xml new file mode 100644 index 00000000..f0f957c9 --- /dev/null +++ b/app/src/main/res/drawable/line_curve.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/link.xml b/app/src/main/res/drawable/link.xml new file mode 100644 index 00000000..1e0f7401 --- /dev/null +++ b/app/src/main/res/drawable/link.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/list.xml b/app/src/main/res/drawable/list.xml new file mode 100644 index 00000000..0389ee0f --- /dev/null +++ b/app/src/main/res/drawable/list.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/location_on.xml b/app/src/main/res/drawable/location_on.xml new file mode 100644 index 00000000..4cd09ed8 --- /dev/null +++ b/app/src/main/res/drawable/location_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lock.xml b/app/src/main/res/drawable/lock.xml new file mode 100644 index 00000000..c279f568 --- /dev/null +++ b/app/src/main/res/drawable/lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/lock_open.xml b/app/src/main/res/drawable/lock_open.xml new file mode 100644 index 00000000..210e5739 --- /dev/null +++ b/app/src/main/res/drawable/lock_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/login.xml b/app/src/main/res/drawable/login.xml new file mode 100644 index 00000000..114f9d31 --- /dev/null +++ b/app/src/main/res/drawable/login.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/logout.xml b/app/src/main/res/drawable/logout.xml new file mode 100644 index 00000000..7d7d6cb8 --- /dev/null +++ b/app/src/main/res/drawable/logout.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/lyrics.xml b/app/src/main/res/drawable/lyrics.xml new file mode 100644 index 00000000..f58a171c --- /dev/null +++ b/app/src/main/res/drawable/lyrics.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/manage_search.xml b/app/src/main/res/drawable/manage_search.xml new file mode 100644 index 00000000..ba575ee0 --- /dev/null +++ b/app/src/main/res/drawable/manage_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/menu.xml b/app/src/main/res/drawable/menu.xml new file mode 100644 index 00000000..48091af6 --- /dev/null +++ b/app/src/main/res/drawable/menu.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/menu_book.xml b/app/src/main/res/drawable/menu_book.xml new file mode 100644 index 00000000..28725411 --- /dev/null +++ b/app/src/main/res/drawable/menu_book.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/mood.xml b/app/src/main/res/drawable/mood.xml new file mode 100644 index 00000000..adb1afa0 --- /dev/null +++ b/app/src/main/res/drawable/mood.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/more_horiz.xml b/app/src/main/res/drawable/more_horiz.xml new file mode 100644 index 00000000..6e093887 --- /dev/null +++ b/app/src/main/res/drawable/more_horiz.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/more_vert.xml b/app/src/main/res/drawable/more_vert.xml new file mode 100644 index 00000000..ddc2ea72 --- /dev/null +++ b/app/src/main/res/drawable/more_vert.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/music_note.xml b/app/src/main/res/drawable/music_note.xml new file mode 100644 index 00000000..7a0582f0 --- /dev/null +++ b/app/src/main/res/drawable/music_note.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/nav_bar.xml b/app/src/main/res/drawable/nav_bar.xml new file mode 100644 index 00000000..357eae84 --- /dev/null +++ b/app/src/main/res/drawable/nav_bar.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/navigate_next.xml b/app/src/main/res/drawable/navigate_next.xml new file mode 100644 index 00000000..9880347d --- /dev/null +++ b/app/src/main/res/drawable/navigate_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/notification_off.xml b/app/src/main/res/drawable/notification_off.xml new file mode 100644 index 00000000..e1f725a2 --- /dev/null +++ b/app/src/main/res/drawable/notification_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/notification_on.xml b/app/src/main/res/drawable/notification_on.xml new file mode 100644 index 00000000..09b61c7f --- /dev/null +++ b/app/src/main/res/drawable/notification_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/offline.xml b/app/src/main/res/drawable/offline.xml new file mode 100644 index 00000000..802c44e7 --- /dev/null +++ b/app/src/main/res/drawable/offline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/open_in_new.xml b/app/src/main/res/drawable/open_in_new.xml new file mode 100644 index 00000000..c23ca26d --- /dev/null +++ b/app/src/main/res/drawable/open_in_new.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/palette.xml b/app/src/main/res/drawable/palette.xml new file mode 100644 index 00000000..114543ba --- /dev/null +++ b/app/src/main/res/drawable/palette.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/pause.xml b/app/src/main/res/drawable/pause.xml new file mode 100644 index 00000000..5137fa04 --- /dev/null +++ b/app/src/main/res/drawable/pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/paypal.xml b/app/src/main/res/drawable/paypal.xml new file mode 100644 index 00000000..f42398fa --- /dev/null +++ b/app/src/main/res/drawable/paypal.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/person.xml b/app/src/main/res/drawable/person.xml new file mode 100644 index 00000000..18405a14 --- /dev/null +++ b/app/src/main/res/drawable/person.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/play.xml b/app/src/main/res/drawable/play.xml new file mode 100644 index 00000000..d6080aca --- /dev/null +++ b/app/src/main/res/drawable/play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/playlist_add.xml b/app/src/main/res/drawable/playlist_add.xml new file mode 100644 index 00000000..97c95b61 --- /dev/null +++ b/app/src/main/res/drawable/playlist_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/playlist_import.xml b/app/src/main/res/drawable/playlist_import.xml new file mode 100644 index 00000000..6afaf5b1 --- /dev/null +++ b/app/src/main/res/drawable/playlist_import.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/playlist_play.xml b/app/src/main/res/drawable/playlist_play.xml new file mode 100644 index 00000000..0e900098 --- /dev/null +++ b/app/src/main/res/drawable/playlist_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/poeditor.xml b/app/src/main/res/drawable/poeditor.xml new file mode 100644 index 00000000..0bcd7ddd --- /dev/null +++ b/app/src/main/res/drawable/poeditor.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/previewalbum.jpg b/app/src/main/res/drawable/previewalbum.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e0ea292e202385dfc6947a5994b98d3c48df9e91 GIT binary patch literal 44192 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%0Dcps~q_dK1EPe?|!{e^(rA_8ip0 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 + + diff --git a/app/src/main/res/drawable/radar.webp b/app/src/main/res/drawable/radar.webp new file mode 100644 index 0000000000000000000000000000000000000000..7dad8ee7d2562db0c55191685f2c1eadaf052685 GIT binary patch literal 1448 zcmV;Z1y}k~Nk&GX1pok7MM6+kP&il$0000G0000#002J#06|PpNK6F)00E%CZTtB) zAJ(0uOgl7VJ66Xr+j=o?X=mHEy%n!*+qP}nW}Uj}H|>(=hx_>=jfe?=>4ug)tniD7*!}*}aV@6R&acGbANTrNLh`N>iDKPFZKLH{T_#ZuY;;8> z`%ST0`Nnm}S1(ww>d=)hms${cGb;-7Ys57C#@=4AoU!f2vnEnA(JA+35z))f^n`<5 zPL^m=m+egK1EN9o%{g9~Db70jwVB&%Wllyxu z04qpx9JoZ(Z?j;UF!z?9!=i?NzIk@Mi7Q0Q?JXDeLm%{>S5=A4$}77mOND3H%C1%3 zNrswoRg*Z_bVXYrCq!YN9rwq=Kl-4~C=!Q@rsRf%AA;S@!hv+KD=#9BN1BAIGVQbr zP-GTdE8lAf{3NEqCpS(=OH64$Zs)^|nr=*m-9q78;dxlB{dL2X zN>_X}RM8G+#G=7;&AxX5SX(c)4L>ZuP2V9+cq;(RFgZIBz=dMp z@Wc8R3b&r6WYAQtY7T%+V&Cw?W`B~2c~l0zlIpgl0LV79|DSuMSTp?4`z`IIcxvhB zgY=^EJv{+_Sh#488*t4l7Lkx2`azNKM#Ag#(1&y##Wd3<>JXy;yF*QTvi|FHq5BLG zsY;+5==Hzp0A&b|rA5Mv;AAi1-jqjQ2WSJGucXq&RP1piRhd9L>HkJrMpG3p-9fLz z(Rd}7mQ%3BB`VWs9aRQs1D&sQri-Z9<46bPT-rl_{Y?ibQyE7KNftXYO39{E^vwr! z9mO=!W@;az|GR5lo(_I9!DJ&f#nAQi?t}Ca;X8Vg@E}R?TsPpJUm_xb6zw7U8vGvJ zn@45Jl~k8RSq6y|J@Qkv(giZouNRAOMA^AI10erc6H{X1bzb*96e+U%NN!hfgCg?hrew^o5)rAKi{veTiGDNG`-Lxs>I;y}Ry;Ro zA`QS?>fph{6gf%C_`=E9!S9ZhH{SnE}?G;S?-KpQsgX;TE z_!EyAvTI2`N>bpxH0VU|QZH2Rd%_KUVRgA8A9YZGj`tX#OMSjWD zfS8Hb)SJ~`Gcg9H=|75k1#G(RP^4ys34jA;`(SZ?K-34_b`blVB65ExGtX_>tsb+E z_lo{f9PG#5tx4^1uLjrb*UHOZo9%+0c(hjZ`{UrG>$E0Pb#=O8Gwsm7VtTb34lk_K zM9#jpua~u6`(8UMrq#>fe784DIPlgDhqo?YzV+}8Zw18aJ3SCh_uBs~I9$BZgD88C zS4X2ZFBvT`Vda-j{d6b=Ep>R_rx81mFA7JYWq0x)zW1YFOB;={-#&ieNNy?|2Ubux zAT9s^0FVO!odGI906+jfkw~CSC8Q%FA_xGm5(#Ml01!7*Pz88}xc~tE_|Els0000H CO1r86 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/radio.xml b/app/src/main/res/drawable/radio.xml new file mode 100644 index 00000000..99379d97 --- /dev/null +++ b/app/src/main/res/drawable/radio.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/radio_button_checked.xml b/app/src/main/res/drawable/radio_button_checked.xml new file mode 100644 index 00000000..49e00936 --- /dev/null +++ b/app/src/main/res/drawable/radio_button_checked.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/radio_button_unchecked.xml b/app/src/main/res/drawable/radio_button_unchecked.xml new file mode 100644 index 00000000..555c26d3 --- /dev/null +++ b/app/src/main/res/drawable/radio_button_unchecked.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/refresh.xml b/app/src/main/res/drawable/refresh.xml new file mode 100644 index 00000000..47079bb5 --- /dev/null +++ b/app/src/main/res/drawable/refresh.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/remove.xml b/app/src/main/res/drawable/remove.xml new file mode 100644 index 00000000..b13fa153 --- /dev/null +++ b/app/src/main/res/drawable/remove.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat.xml b/app/src/main/res/drawable/repeat.xml new file mode 100644 index 00000000..715ca2c1 --- /dev/null +++ b/app/src/main/res/drawable/repeat.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat_on.xml b/app/src/main/res/drawable/repeat_on.xml new file mode 100644 index 00000000..3581db00 --- /dev/null +++ b/app/src/main/res/drawable/repeat_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat_one.xml b/app/src/main/res/drawable/repeat_one.xml new file mode 100644 index 00000000..a0cb6528 --- /dev/null +++ b/app/src/main/res/drawable/repeat_one.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/repeat_one_on.xml b/app/src/main/res/drawable/repeat_one_on.xml new file mode 100644 index 00000000..2b5b585e --- /dev/null +++ b/app/src/main/res/drawable/repeat_one_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/replay.xml b/app/src/main/res/drawable/replay.xml new file mode 100644 index 00000000..59725203 --- /dev/null +++ b/app/src/main/res/drawable/replay.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/resource_public.xml b/app/src/main/res/drawable/resource_public.xml new file mode 100644 index 00000000..1bf3a557 --- /dev/null +++ b/app/src/main/res/drawable/resource_public.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/restore.xml b/app/src/main/res/drawable/restore.xml new file mode 100644 index 00000000..7e1e9e70 --- /dev/null +++ b/app/src/main/res/drawable/restore.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/robot.xml b/app/src/main/res/drawable/robot.xml new file mode 100644 index 00000000..d4838761 --- /dev/null +++ b/app/src/main/res/drawable/robot.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/rounded_image_bg.xml b/app/src/main/res/drawable/rounded_image_bg.xml new file mode 100644 index 00000000..d2cd4ab7 --- /dev/null +++ b/app/src/main/res/drawable/rounded_image_bg.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/scatter_plot.xml b/app/src/main/res/drawable/scatter_plot.xml new file mode 100644 index 00000000..0995f0ba --- /dev/null +++ b/app/src/main/res/drawable/scatter_plot.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/schedule.xml b/app/src/main/res/drawable/schedule.xml new file mode 100644 index 00000000..a5807201 --- /dev/null +++ b/app/src/main/res/drawable/schedule.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/screenshot.xml b/app/src/main/res/drawable/screenshot.xml new file mode 100644 index 00000000..2d5fb121 --- /dev/null +++ b/app/src/main/res/drawable/screenshot.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/search.xml b/app/src/main/res/drawable/search.xml new file mode 100644 index 00000000..0965a8fb --- /dev/null +++ b/app/src/main/res/drawable/search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/search_off.xml b/app/src/main/res/drawable/search_off.xml new file mode 100644 index 00000000..21affc31 --- /dev/null +++ b/app/src/main/res/drawable/search_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/security.xml b/app/src/main/res/drawable/security.xml new file mode 100644 index 00000000..3073847b --- /dev/null +++ b/app/src/main/res/drawable/security.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/select.xml b/app/src/main/res/drawable/select.xml new file mode 100644 index 00000000..c9297986 --- /dev/null +++ b/app/src/main/res/drawable/select.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/select_all.xml b/app/src/main/res/drawable/select_all.xml new file mode 100644 index 00000000..5714fcb2 --- /dev/null +++ b/app/src/main/res/drawable/select_all.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml new file mode 100644 index 00000000..57c7adb7 --- /dev/null +++ b/app/src/main/res/drawable/settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settingsv2.xml b/app/src/main/res/drawable/settingsv2.xml new file mode 100644 index 00000000..0768fab1 --- /dev/null +++ b/app/src/main/res/drawable/settingsv2.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/share.xml b/app/src/main/res/drawable/share.xml new file mode 100644 index 00000000..1402fd11 --- /dev/null +++ b/app/src/main/res/drawable/share.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shortcut_albums.xml b/app/src/main/res/drawable/shortcut_albums.xml new file mode 100644 index 00000000..7aed64f3 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_albums.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_explore.xml b/app/src/main/res/drawable/shortcut_explore.xml new file mode 100644 index 00000000..e194d182 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_explore.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_library.xml b/app/src/main/res/drawable/shortcut_library.xml new file mode 100644 index 00000000..d82d5b4d --- /dev/null +++ b/app/src/main/res/drawable/shortcut_library.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shortcut_playlists.xml b/app/src/main/res/drawable/shortcut_playlists.xml new file mode 100644 index 00000000..eba0fe7e --- /dev/null +++ b/app/src/main/res/drawable/shortcut_playlists.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_search.xml b/app/src/main/res/drawable/shortcut_search.xml new file mode 100644 index 00000000..c56846b0 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_search.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_songs.xml b/app/src/main/res/drawable/shortcut_songs.xml new file mode 100644 index 00000000..710ec001 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_songs.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shuffle.xml b/app/src/main/res/drawable/shuffle.xml new file mode 100644 index 00000000..1c1c923f --- /dev/null +++ b/app/src/main/res/drawable/shuffle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shuffle_on.xml b/app/src/main/res/drawable/shuffle_on.xml new file mode 100644 index 00000000..e18e294e --- /dev/null +++ b/app/src/main/res/drawable/shuffle_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/signal_cellular_nodata.xml b/app/src/main/res/drawable/signal_cellular_nodata.xml new file mode 100644 index 00000000..7f1eca2a --- /dev/null +++ b/app/src/main/res/drawable/signal_cellular_nodata.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/similar.xml b/app/src/main/res/drawable/similar.xml new file mode 100644 index 00000000..98ea3984 --- /dev/null +++ b/app/src/main/res/drawable/similar.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/skip_next.xml b/app/src/main/res/drawable/skip_next.xml new file mode 100644 index 00000000..8bdd141a --- /dev/null +++ b/app/src/main/res/drawable/skip_next.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/skip_previous.xml b/app/src/main/res/drawable/skip_previous.xml new file mode 100644 index 00000000..f71ae1d7 --- /dev/null +++ b/app/src/main/res/drawable/skip_previous.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/sliders.xml b/app/src/main/res/drawable/sliders.xml new file mode 100644 index 00000000..e916d263 --- /dev/null +++ b/app/src/main/res/drawable/sliders.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/slow_motion_video.xml b/app/src/main/res/drawable/slow_motion_video.xml new file mode 100644 index 00000000..69ed770a --- /dev/null +++ b/app/src/main/res/drawable/slow_motion_video.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/speed.xml b/app/src/main/res/drawable/speed.xml new file mode 100644 index 00000000..37bb5bf3 --- /dev/null +++ b/app/src/main/res/drawable/speed.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/storage.xml b/app/src/main/res/drawable/storage.xml new file mode 100644 index 00000000..8efdcfec --- /dev/null +++ b/app/src/main/res/drawable/storage.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/subscribe.xml b/app/src/main/res/drawable/subscribe.xml new file mode 100644 index 00000000..66dd52da --- /dev/null +++ b/app/src/main/res/drawable/subscribe.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/subscribed.xml b/app/src/main/res/drawable/subscribed.xml new file mode 100644 index 00000000..f0a8779d --- /dev/null +++ b/app/src/main/res/drawable/subscribed.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/support_agent.xml b/app/src/main/res/drawable/support_agent.xml new file mode 100644 index 00000000..6e2d3b9e --- /dev/null +++ b/app/src/main/res/drawable/support_agent.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/swipe.xml b/app/src/main/res/drawable/swipe.xml new file mode 100644 index 00000000..43ee319a --- /dev/null +++ b/app/src/main/res/drawable/swipe.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/drawable/sync.xml b/app/src/main/res/drawable/sync.xml new file mode 100644 index 00000000..fb34c6fc --- /dev/null +++ b/app/src/main/res/drawable/sync.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tab.xml b/app/src/main/res/drawable/tab.xml new file mode 100644 index 00000000..b6ee503d --- /dev/null +++ b/app/src/main/res/drawable/tab.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/telegram.xml b/app/src/main/res/drawable/telegram.xml new file mode 100644 index 00000000..a840b072 --- /dev/null +++ b/app/src/main/res/drawable/telegram.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tensorflow.xml b/app/src/main/res/drawable/tensorflow.xml new file mode 100644 index 00000000..5428ac0c --- /dev/null +++ b/app/src/main/res/drawable/tensorflow.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/thumb_up.xml b/app/src/main/res/drawable/thumb_up.xml new file mode 100644 index 00000000..da482696 --- /dev/null +++ b/app/src/main/res/drawable/thumb_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/token.xml b/app/src/main/res/drawable/token.xml new file mode 100644 index 00000000..80133620 --- /dev/null +++ b/app/src/main/res/drawable/token.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/translate.xml b/app/src/main/res/drawable/translate.xml new file mode 100644 index 00000000..c1d325b9 --- /dev/null +++ b/app/src/main/res/drawable/translate.xml @@ -0,0 +1,9 @@ + + + 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 00000000..b87ff738 --- /dev/null +++ b/app/src/main/res/drawable/trending_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tune.xml b/app/src/main/res/drawable/tune.xml new file mode 100644 index 00000000..9ab9dfc7 --- /dev/null +++ b/app/src/main/res/drawable/tune.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/uncheck_box.xml b/app/src/main/res/drawable/uncheck_box.xml new file mode 100644 index 00000000..f195d703 --- /dev/null +++ b/app/src/main/res/drawable/uncheck_box.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/update.xml b/app/src/main/res/drawable/update.xml new file mode 100644 index 00000000..65fd69ac --- /dev/null +++ b/app/src/main/res/drawable/update.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/verified_user.xml b/app/src/main/res/drawable/verified_user.xml new file mode 100644 index 00000000..3561e408 --- /dev/null +++ b/app/src/main/res/drawable/verified_user.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/visibility.xml b/app/src/main/res/drawable/visibility.xml new file mode 100644 index 00000000..166136b5 --- /dev/null +++ b/app/src/main/res/drawable/visibility.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/visibility_off.xml b/app/src/main/res/drawable/visibility_off.xml new file mode 100644 index 00000000..2ac129cb --- /dev/null +++ b/app/src/main/res/drawable/visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/volume_off.xml b/app/src/main/res/drawable/volume_off.xml new file mode 100644 index 00000000..cff802a5 --- /dev/null +++ b/app/src/main/res/drawable/volume_off.xml @@ -0,0 +1,9 @@ + + + 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 00000000..1e871188 --- /dev/null +++ b/app/src/main/res/drawable/volume_up.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/waves.xml b/app/src/main/res/drawable/waves.xml new file mode 100644 index 00000000..cde22003 --- /dev/null +++ b/app/src/main/res/drawable/waves.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_background.xml b/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 00000000..b40a1ae3 --- /dev/null +++ b/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/widget_preview.png b/app/src/main/res/drawable/widget_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..bcd9ebb0a7b23b08203d5eeb05a1f9844b739e5c GIT binary patch literal 593089 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