Repository: MetrolistGroup/Metrolist Branch: main Commit: c1802c079c62 Files: 1016 Total size: 8.3 MB Directory structure: gitextract__a69pf7r/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── actions/ │ │ └── setup-protobuf/ │ │ └── action.yml │ ├── pull_request_template.md │ ├── scripts/ │ │ └── parse_changelog.sh │ └── workflows/ │ ├── build.yml │ ├── build_pr.yml │ ├── build_quick.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── AGENTS.md ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── generate_proto.sh │ ├── lint.xml │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── com.metrolist.music.db.InternalDatabase/ │ │ ├── 1.json │ │ ├── 10.json │ │ ├── 11.json │ │ ├── 12.json │ │ ├── 13.json │ │ ├── 14.json │ │ ├── 15.json │ │ ├── 16.json │ │ ├── 17.json │ │ ├── 18.json │ │ ├── 19.json │ │ ├── 2.json │ │ ├── 20.json │ │ ├── 21.json │ │ ├── 22.json │ │ ├── 23.json │ │ ├── 24.json │ │ ├── 25.json │ │ ├── 26.json │ │ ├── 27.json │ │ ├── 28.json │ │ ├── 29.json │ │ ├── 3.json │ │ ├── 30.json │ │ ├── 31.json │ │ ├── 32.json │ │ ├── 33.json │ │ ├── 34.json │ │ ├── 35.json │ │ ├── 36.json │ │ ├── 4.json │ │ ├── 5.json │ │ ├── 6.json │ │ ├── 7.json │ │ ├── 8.json │ │ └── 9.json │ └── src/ │ ├── debug/ │ │ └── res/ │ │ └── xml/ │ │ └── shortcuts.xml │ ├── foss/ │ │ ├── AndroidManifest.xml │ │ └── kotlin/ │ │ └── com/ │ │ └── metrolist/ │ │ └── music/ │ │ ├── cast/ │ │ │ └── CastOptionsProvider.kt │ │ ├── playback/ │ │ │ └── CastConnectionHandler.kt │ │ └── ui/ │ │ └── component/ │ │ └── CastButton.kt │ ├── gms/ │ │ └── kotlin/ │ │ └── com/ │ │ └── metrolist/ │ │ └── music/ │ │ ├── cast/ │ │ │ ├── CastManager.kt │ │ │ └── CastOptionsProvider.kt │ │ ├── playback/ │ │ │ └── CastConnectionHandler.kt │ │ └── ui/ │ │ └── component/ │ │ ├── CastButton.kt │ │ └── CastPickerSheet.kt │ ├── izzy/ │ │ ├── AndroidManifest.xml │ │ └── kotlin/ │ │ └── com/ │ │ └── metrolist/ │ │ └── music/ │ │ ├── cast/ │ │ │ └── CastOptionsProvider.kt │ │ ├── playback/ │ │ │ └── CastConnectionHandler.kt │ │ └── ui/ │ │ └── component/ │ │ └── CastButton.kt │ └── main/ │ ├── AndroidManifest.xml │ ├── assets/ │ │ ├── po_token.html │ │ └── solver/ │ │ ├── astring.js │ │ ├── meriyah.js │ │ └── yt.solver.core.js │ ├── kotlin/ │ │ └── com/ │ │ ├── dpi/ │ │ │ ├── ActivityLifecycleManager.kt │ │ │ ├── BaseLifecycleContentProvider.kt │ │ │ ├── DensityConfiguration.kt │ │ │ └── DensityScaler.kt │ │ └── metrolist/ │ │ └── music/ │ │ ├── App.kt │ │ ├── MainActivity.kt │ │ ├── api/ │ │ │ ├── DeepLService.kt │ │ │ ├── MistralService.kt │ │ │ ├── OpenRouterService.kt │ │ │ └── OpenRouterStreamingService.kt │ │ ├── constants/ │ │ │ ├── Dimensions.kt │ │ │ ├── HistorySource.kt │ │ │ ├── LibraryFilter.kt │ │ │ ├── MediaSessionConstants.kt │ │ │ ├── PreferenceKeys.kt │ │ │ └── StatPeriod.kt │ │ ├── db/ │ │ │ ├── Converters.kt │ │ │ ├── DatabaseDao.kt │ │ │ ├── MusicDatabase.kt │ │ │ ├── daos/ │ │ │ │ └── SpeedDialDao.kt │ │ │ └── entities/ │ │ │ ├── Album.kt │ │ │ ├── AlbumArtistMap.kt │ │ │ ├── AlbumEntity.kt │ │ │ ├── AlbumWithSongs.kt │ │ │ ├── Artist.kt │ │ │ ├── ArtistEntity.kt │ │ │ ├── Event.kt │ │ │ ├── EventWithSong.kt │ │ │ ├── FormatEntity.kt │ │ │ ├── LocalItem.kt │ │ │ ├── LyricsEntity.kt │ │ │ ├── PlayCountEntity.kt │ │ │ ├── Playlist.kt │ │ │ ├── PlaylistEntity.kt │ │ │ ├── PlaylistSong.kt │ │ │ ├── PlaylistSongMap.kt │ │ │ ├── PlaylistSongMapPreview.kt │ │ │ ├── PodcastEntity.kt │ │ │ ├── RecognitionHistory.kt │ │ │ ├── RelatedSongMap.kt │ │ │ ├── SearchHistory.kt │ │ │ ├── SetVideoIdEntity.kt │ │ │ ├── Song.kt │ │ │ ├── SongAlbumMap.kt │ │ │ ├── SongArtistMap.kt │ │ │ ├── SongEntity.kt │ │ │ ├── SongWithStats.kt │ │ │ ├── SortedSongAlbumMap.kt │ │ │ ├── SortedSongArtistMap.kt │ │ │ └── SpeedDialItem.kt │ │ ├── di/ │ │ │ ├── AppModule.kt │ │ │ ├── LyricsHelperEntryPoint.kt │ │ │ ├── NetworkModule.kt │ │ │ ├── Qualifiers.kt │ │ │ └── WrappedModule.kt │ │ ├── eq/ │ │ │ ├── EqualizerService.kt │ │ │ ├── audio/ │ │ │ │ ├── BiquadFilter.kt │ │ │ │ └── CustomEqualizerAudioProcessor.kt │ │ │ └── data/ │ │ │ ├── EQProfileRepository.kt │ │ │ ├── FilterType.kt │ │ │ ├── ParametricEQ.kt │ │ │ └── ParametricEQParser.kt │ │ ├── extensions/ │ │ │ ├── ContextExt.kt │ │ │ ├── CoroutineExt.kt │ │ │ ├── FileExt.kt │ │ │ ├── ListExt.kt │ │ │ ├── MediaItemExt.kt │ │ │ ├── PlayerExt.kt │ │ │ ├── QueueExt.kt │ │ │ ├── StringExt.kt │ │ │ └── UtilExt.kt │ │ ├── listentogether/ │ │ │ ├── ListenTogetherActionReceiver.kt │ │ │ ├── ListenTogetherClient.kt │ │ │ ├── ListenTogetherManager.kt │ │ │ ├── ListenTogetherServers.kt │ │ │ ├── MessageCodec.kt │ │ │ └── Protocol.kt │ │ ├── lyrics/ │ │ │ ├── BetterLyricsProvider.kt │ │ │ ├── KuGouLyricsProvider.kt │ │ │ ├── LrcLibLyricsProvider.kt │ │ │ ├── LyricsEntry.kt │ │ │ ├── LyricsHelper.kt │ │ │ ├── LyricsPlusProvider.kt │ │ │ ├── LyricsProvider.kt │ │ │ ├── LyricsProviderRegistry.kt │ │ │ ├── LyricsTranslationHelper.kt │ │ │ ├── LyricsUtils.kt │ │ │ ├── SimpMusicLyricsProvider.kt │ │ │ ├── YouTubeLyricsProvider.kt │ │ │ └── YouTubeSubtitleLyricsProvider.kt │ │ ├── models/ │ │ │ ├── ItemsPage.kt │ │ │ ├── MediaMetadata.kt │ │ │ ├── PersistPlayerState.kt │ │ │ ├── PersistQueue.kt │ │ │ └── SimilarRecommendation.kt │ │ ├── playback/ │ │ │ ├── DownloadUtil.kt │ │ │ ├── ExoDownloadService.kt │ │ │ ├── MediaLibrarySessionCallback.kt │ │ │ ├── MetrolistCacheEvictor.kt │ │ │ ├── MusicService.kt │ │ │ ├── PlayerConnection.kt │ │ │ ├── SleepTimer.kt │ │ │ ├── alarm/ │ │ │ │ ├── MusicAlarmReceiver.kt │ │ │ │ ├── MusicAlarmRescheduleReceiver.kt │ │ │ │ ├── MusicAlarmScheduler.kt │ │ │ │ └── MusicAlarmStore.kt │ │ │ ├── audio/ │ │ │ │ └── SilenceDetectorAudioProcessor.kt │ │ │ └── queues/ │ │ │ ├── EmptyQueue.kt │ │ │ ├── ListQueue.kt │ │ │ ├── LocalAlbumRadio.kt │ │ │ ├── Queue.kt │ │ │ ├── YouTubeAlbumRadio.kt │ │ │ ├── YouTubePlaylistQueue.kt │ │ │ └── YouTubeQueue.kt │ │ ├── quicksettings/ │ │ │ └── MusicRecognizerTileService.kt │ │ ├── recognition/ │ │ │ ├── AudioResampler.kt │ │ │ ├── MusicRecognitionService.kt │ │ │ ├── RecognitionForegroundService.kt │ │ │ ├── RecognitionLaunchActivity.kt │ │ │ ├── ShazamSignatureGenerator.kt │ │ │ └── VibraSignature.kt │ │ ├── ui/ │ │ │ ├── component/ │ │ │ │ ├── AppNavigation.kt │ │ │ │ ├── AutoResizeText.kt │ │ │ │ ├── BigSeekBar.kt │ │ │ │ ├── BottomSheet.kt │ │ │ │ ├── BottomSheetMenu.kt │ │ │ │ ├── BottomSheetPage.kt │ │ │ │ ├── ChipsRow.kt │ │ │ │ ├── CreatePlaylistDialog.kt │ │ │ │ ├── Dialog.kt │ │ │ │ ├── DraggableLyricsProviderList.kt │ │ │ │ ├── DraggableScrollBarOverlay.kt │ │ │ │ ├── EmptyPlaceholder.kt │ │ │ │ ├── EnumDialog.kt │ │ │ │ ├── ExpandableText.kt │ │ │ │ ├── GridMenu.kt │ │ │ │ ├── HideOnScrollFAB.kt │ │ │ │ ├── IconButton.kt │ │ │ │ ├── IntegrationCard.kt │ │ │ │ ├── Items.kt │ │ │ │ ├── Library.kt │ │ │ │ ├── Lyrics.kt │ │ │ │ ├── LyricsImageCard.kt │ │ │ │ ├── Material3SettingsGroup.kt │ │ │ │ ├── Menu.kt │ │ │ │ ├── NavigationTile.kt │ │ │ │ ├── NavigationTitle.kt │ │ │ │ ├── NewMenuComponents.kt │ │ │ │ ├── PlayerSlider.kt │ │ │ │ ├── PlayingIndicator.kt │ │ │ │ ├── Preference.kt │ │ │ │ ├── RandomizeGridItem.kt │ │ │ │ ├── ReleaseNotesCard.kt │ │ │ │ ├── SearchBar.kt │ │ │ │ ├── SettingsSleepTimerDialog.kt │ │ │ │ ├── SongDropdownSelect.kt │ │ │ │ ├── SortHeader.kt │ │ │ │ ├── SpeedDialGridItem.kt │ │ │ │ ├── SquigglySlider.kt │ │ │ │ ├── TimeTransfer.kt │ │ │ │ ├── VolumeSlider.kt │ │ │ │ ├── WavySlider.kt │ │ │ │ └── shimmer/ │ │ │ │ ├── ButtonPlaceholder.kt │ │ │ │ ├── GridItemPlaceholder.kt │ │ │ │ ├── ListItemPlaceholder.kt │ │ │ │ ├── ShimmerHost.kt │ │ │ │ └── TextPlaceholder.kt │ │ │ ├── menu/ │ │ │ │ ├── AddToPlaylistDialog.kt │ │ │ │ ├── AddToPlaylistDialogOnline.kt │ │ │ │ ├── AlbumMenu.kt │ │ │ │ ├── ArtistMenu.kt │ │ │ │ ├── CsvColumnMappingDialog.kt │ │ │ │ ├── CustomThumbnailMenu.kt │ │ │ │ ├── ImportPlaylistDialog.kt │ │ │ │ ├── LoadingScreen.kt │ │ │ │ ├── LyricsMenu.kt │ │ │ │ ├── PlayerMenu.kt │ │ │ │ ├── PlaylistMenu.kt │ │ │ │ ├── PlaylistScreenMenus.kt │ │ │ │ ├── QueueMenu.kt │ │ │ │ ├── SelectionSongsMenu.kt │ │ │ │ ├── SongMenu.kt │ │ │ │ ├── YouTubeAlbumMenu.kt │ │ │ │ ├── YouTubeArtistMenu.kt │ │ │ │ ├── YouTubePlaylistMenu.kt │ │ │ │ ├── YouTubeSelectionSongMenu.kt │ │ │ │ └── YouTubeSongMenu.kt │ │ │ ├── player/ │ │ │ │ ├── MiniPlayer.kt │ │ │ │ ├── PlaybackError.kt │ │ │ │ ├── Player.kt │ │ │ │ ├── Queue.kt │ │ │ │ ├── Thumbnail.kt │ │ │ │ └── ThumbnailSnapUtils.kt │ │ │ ├── screens/ │ │ │ │ ├── AccountScreen.kt │ │ │ │ ├── AlbumScreen.kt │ │ │ │ ├── BrowseScreen.kt │ │ │ │ ├── ChartsScreen.kt │ │ │ │ ├── CrashActivity.kt │ │ │ │ ├── ExploreScreen.kt │ │ │ │ ├── HistoryScreen.kt │ │ │ │ ├── HomeScreen.kt │ │ │ │ ├── ListenTogetherScreen.kt │ │ │ │ ├── LoginScreen.kt │ │ │ │ ├── MoodAndGenresScreen.kt │ │ │ │ ├── NavigationBuilder.kt │ │ │ │ ├── NewReleaseScreen.kt │ │ │ │ ├── Screens.kt │ │ │ │ ├── StatsScreen.kt │ │ │ │ ├── YouTubeBrowseScreen.kt │ │ │ │ ├── artist/ │ │ │ │ │ ├── ArtistAlbumsScreen.kt │ │ │ │ │ ├── ArtistItemsScreen.kt │ │ │ │ │ ├── ArtistScreen.kt │ │ │ │ │ └── ArtistSongsScreen.kt │ │ │ │ ├── equalizer/ │ │ │ │ │ ├── EQState.kt │ │ │ │ │ ├── EQViewModel.kt │ │ │ │ │ └── EqScreen.kt │ │ │ │ ├── library/ │ │ │ │ │ ├── LibraryAlbumsScreen.kt │ │ │ │ │ ├── LibraryArtistsScreen.kt │ │ │ │ │ ├── LibraryMixScreen.kt │ │ │ │ │ ├── LibraryPlaylistsScreen.kt │ │ │ │ │ ├── LibraryPodcastsScreen.kt │ │ │ │ │ ├── LibraryScreen.kt │ │ │ │ │ └── LibrarySongsScreen.kt │ │ │ │ ├── playlist/ │ │ │ │ │ ├── AutoPlaylistScreen.kt │ │ │ │ │ ├── CachePlaylistScreen.kt │ │ │ │ │ ├── LocalPlaylistScreen.kt │ │ │ │ │ ├── OnlinePlaylistScreen.kt │ │ │ │ │ └── TopPlaylistScreen.kt │ │ │ │ ├── podcast/ │ │ │ │ │ └── OnlinePodcastScreen.kt │ │ │ │ ├── recognition/ │ │ │ │ │ ├── RecognitionHistoryScreen.kt │ │ │ │ │ └── RecognitionScreen.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── LocalSearchScreen.kt │ │ │ │ │ ├── OnlineSearchResult.kt │ │ │ │ │ ├── OnlineSearchScreen.kt │ │ │ │ │ └── SearchScreen.kt │ │ │ │ ├── settings/ │ │ │ │ │ ├── AboutScreen.kt │ │ │ │ │ ├── AccountSettings.kt │ │ │ │ │ ├── AiSettings.kt │ │ │ │ │ ├── AlarmSettings.kt │ │ │ │ │ ├── AndroidAutoSettings.kt │ │ │ │ │ ├── AppearanceSettings.kt │ │ │ │ │ ├── BackupAndRestore.kt │ │ │ │ │ ├── ChangelogScreen.kt │ │ │ │ │ ├── ContentSettings.kt │ │ │ │ │ ├── DiscordLoginScreen.kt │ │ │ │ │ ├── PlayerSettings.kt │ │ │ │ │ ├── PrivacySettings.kt │ │ │ │ │ ├── RomanizationSettings.kt │ │ │ │ │ ├── SettingsScreen.kt │ │ │ │ │ ├── StorageSettings.kt │ │ │ │ │ ├── ThemeScreen.kt │ │ │ │ │ ├── UpdaterSettings.kt │ │ │ │ │ └── integrations/ │ │ │ │ │ ├── DiscordSettings.kt │ │ │ │ │ ├── IntegrationScreen.kt │ │ │ │ │ ├── LastFMSettings.kt │ │ │ │ │ └── ListenTogetherSettings.kt │ │ │ │ └── wrapped/ │ │ │ │ ├── WrappedAudioService.kt │ │ │ │ ├── WrappedConstants.kt │ │ │ │ ├── WrappedData.kt │ │ │ │ ├── WrappedEntryPoint.kt │ │ │ │ ├── WrappedManager.kt │ │ │ │ ├── WrappedScreen.kt │ │ │ │ ├── WrappedState.kt │ │ │ │ ├── WrappedViewModel.kt │ │ │ │ ├── components/ │ │ │ │ │ ├── AnimatedBackground.kt │ │ │ │ │ ├── AnimatedDecorativeElement.kt │ │ │ │ │ └── AutoResizingText.kt │ │ │ │ └── pages/ │ │ │ │ ├── AlbumPages.kt │ │ │ │ ├── ConclusionPage.kt │ │ │ │ ├── PlaylistPage.kt │ │ │ │ ├── WrappedIntro.kt │ │ │ │ ├── WrappedMinutesScreen.kt │ │ │ │ ├── WrappedMinutesTease.kt │ │ │ │ ├── WrappedTop5ArtistsScreen.kt │ │ │ │ ├── WrappedTop5SongsScreen.kt │ │ │ │ ├── WrappedTopArtistScreen.kt │ │ │ │ ├── WrappedTopSongScreen.kt │ │ │ │ ├── WrappedTotalArtistsScreen.kt │ │ │ │ └── WrappedTotalSongsScreen.kt │ │ │ ├── theme/ │ │ │ │ ├── Font.kt │ │ │ │ ├── PlayerColorExtractor.kt │ │ │ │ ├── PlayerSliderColors.kt │ │ │ │ ├── Theme.kt │ │ │ │ ├── Type.kt │ │ │ │ └── bbh_bartle.kt │ │ │ └── utils/ │ │ │ ├── AppBar.kt │ │ │ ├── FadingEdge.kt │ │ │ ├── ItemWrapper.kt │ │ │ ├── KeyUtils.kt │ │ │ ├── LazyGridSnapLayoutInfoProvider.kt │ │ │ ├── NavControllerUtils.kt │ │ │ ├── ScrollUtils.kt │ │ │ ├── ShapeUtils.kt │ │ │ ├── ShowMediaInfo.kt │ │ │ ├── ShowOffsetDialog.kt │ │ │ ├── StringUtils.kt │ │ │ └── YouTubeUtils.kt │ │ ├── utils/ │ │ │ ├── CoilBitmapLoader.kt │ │ │ ├── ComposeDebugUtils.kt │ │ │ ├── ComposeToImage.kt │ │ │ ├── CrashHandler.kt │ │ │ ├── DataStore.kt │ │ │ ├── DiscordRPC.kt │ │ │ ├── IconUtils.kt │ │ │ ├── NetworkConnectivityObserver.kt │ │ │ ├── NetworkUtils.kt │ │ │ ├── PlaylistExporter.kt │ │ │ ├── PodcastRefreshTrigger.kt │ │ │ ├── ScrobbleManager.kt │ │ │ ├── StringUtils.kt │ │ │ ├── SuperProperties.kt │ │ │ ├── SyncUtils.kt │ │ │ ├── Updater.kt │ │ │ ├── Utils.kt │ │ │ ├── YTPlayerUtils.kt │ │ │ ├── cipher/ │ │ │ │ ├── CipherDeobfuscator.kt │ │ │ │ ├── CipherWebView.kt │ │ │ │ ├── FunctionNameExtractor.kt │ │ │ │ └── PlayerJsFetcher.kt │ │ │ ├── potoken/ │ │ │ │ ├── JavaScriptUtil.kt │ │ │ │ ├── PoTokenException.kt │ │ │ │ ├── PoTokenGenerator.kt │ │ │ │ ├── PoTokenResult.kt │ │ │ │ └── PoTokenWebView.kt │ │ │ └── sabr/ │ │ │ ├── EjsNTransformSolver.kt │ │ │ └── SabrException.kt │ │ ├── viewmodels/ │ │ │ ├── AccountSettingsViewModel.kt │ │ │ ├── AccountViewModel.kt │ │ │ ├── AlbumViewModel.kt │ │ │ ├── ArtistAlbumsViewModel.kt │ │ │ ├── ArtistItemsViewModel.kt │ │ │ ├── ArtistViewModel.kt │ │ │ ├── AutoPlaylistViewModel.kt │ │ │ ├── BackupRestoreViewModel.kt │ │ │ ├── BrowseViewModel.kt │ │ │ ├── CachePlaylistViewModel.kt │ │ │ ├── ChartsViewModel.kt │ │ │ ├── ExploreViewModel.kt │ │ │ ├── HistoryViewModel.kt │ │ │ ├── HomeViewModel.kt │ │ │ ├── LibraryViewModels.kt │ │ │ ├── ListenTogetherViewModel.kt │ │ │ ├── LocalPlaylistViewModel.kt │ │ │ ├── LocalSearchViewModel.kt │ │ │ ├── LyricsMenuViewModel.kt │ │ │ ├── MoodAndGenresViewModel.kt │ │ │ ├── NewReleaseViewModel.kt │ │ │ ├── OnlinePlaylistViewModel.kt │ │ │ ├── OnlinePodcastViewModel.kt │ │ │ ├── OnlineSearchSuggestionViewModel.kt │ │ │ ├── OnlineSearchViewModel.kt │ │ │ ├── PlaylistsViewModel.kt │ │ │ ├── StatsViewModel.kt │ │ │ ├── ThemeViewModel.kt │ │ │ ├── TopPlaylistViewModel.kt │ │ │ └── YouTubeBrowseViewModel.kt │ │ └── widget/ │ │ ├── MetrolistWidgetManager.kt │ │ ├── MusicRecognizerWidgetReceiver.kt │ │ ├── MusicRecognizerWidgetService.kt │ │ ├── MusicWidgetReceiver.kt │ │ └── TurntableWidgetReceiver.kt │ └── res/ │ ├── drawable/ │ │ ├── account.xml │ │ ├── add.xml │ │ ├── add_circle.xml │ │ ├── album.xml │ │ ├── alphabet_cyrillic.xml │ │ ├── app_logo.xml │ │ ├── arrow_back.xml │ │ ├── arrow_downward.xml │ │ ├── arrow_forward.xml │ │ ├── arrow_top_left.xml │ │ ├── arrow_upward.xml │ │ ├── artist.xml │ │ ├── backup.xml │ │ ├── baseline_event_repeat_24.xml │ │ ├── bedtime.xml │ │ ├── bluetooth.xml │ │ ├── bug_report.xml │ │ ├── buymeacoffee.xml │ │ ├── cached.xml │ │ ├── cast.xml │ │ ├── cast_connected.xml │ │ ├── check.xml │ │ ├── clear_all.xml │ │ ├── close.xml │ │ ├── cloud.xml │ │ ├── content_copy.xml │ │ ├── contrast.xml │ │ ├── crop.xml │ │ ├── crown.xml │ │ ├── delete.xml │ │ ├── delete_history.xml │ │ ├── discord.xml │ │ ├── discover_tune.xml │ │ ├── dock_to_top.xml │ │ ├── done.xml │ │ ├── download.xml │ │ ├── drag_handle.xml │ │ ├── edit.xml │ │ ├── equalizer.xml │ │ ├── error.xml │ │ ├── expand_less.xml │ │ ├── expand_more.xml │ │ ├── explicit.xml │ │ ├── explore_outlined.xml │ │ ├── fast_forward.xml │ │ ├── favorite.xml │ │ ├── favorite_border.xml │ │ ├── fullscreen.xml │ │ ├── github.xml │ │ ├── gradient.xml │ │ ├── graphic_eq.xml │ │ ├── grid_view.xml │ │ ├── group.xml │ │ ├── group_add.xml │ │ ├── group_filled.xml │ │ ├── group_outlined.xml │ │ ├── hide_image.xml │ │ ├── history.xml │ │ ├── home_filled.xml │ │ ├── home_outlined.xml │ │ ├── ic_android_auto.xml │ │ ├── ic_dynamic_icon.xml │ │ ├── ic_heart.xml │ │ ├── ic_heart_outline.xml │ │ ├── ic_launcher_background_v31.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_launcher_foreground_v31.xml │ │ ├── ic_launcher_monochrome.xml │ │ ├── ic_push_pin.xml │ │ ├── ic_widget_heart_nav.xml │ │ ├── ic_widget_heart_outline_nav.xml │ │ ├── ic_widget_mic.xml │ │ ├── ic_widget_pause.xml │ │ ├── ic_widget_pause_low.xml │ │ ├── ic_widget_pause_secondary.xml │ │ ├── ic_widget_play.xml │ │ ├── ic_widget_play_low.xml │ │ ├── ic_widget_play_secondary.xml │ │ ├── ic_widget_skip_next.xml │ │ ├── ic_widget_skip_previous.xml │ │ ├── info.xml │ │ ├── insert_photo.xml │ │ ├── instagram.xml │ │ ├── integration.xml │ │ ├── key.xml │ │ ├── language.xml │ │ ├── language_japanese_latin.xml │ │ ├── language_korean_latin.xml │ │ ├── library_add.xml │ │ ├── library_add_check.xml │ │ ├── library_music.xml │ │ ├── library_music_filled.xml │ │ ├── library_music_outlined.xml │ │ ├── linear_scale.xml │ │ ├── link.xml │ │ ├── list.xml │ │ ├── location_on.xml │ │ ├── lock.xml │ │ ├── lock_open.xml │ │ ├── login.xml │ │ ├── logout.xml │ │ ├── lyrics.xml │ │ ├── manage_search.xml │ │ ├── mic.xml │ │ ├── more_horiz.xml │ │ ├── more_time.xml │ │ ├── more_vert.xml │ │ ├── music_note.xml │ │ ├── nav_bar.xml │ │ ├── navigate_next.xml │ │ ├── newspaper.xml │ │ ├── notification.xml │ │ ├── offline.xml │ │ ├── palette.xml │ │ ├── pause.xml │ │ ├── person.xml │ │ ├── play.xml │ │ ├── playlist_add.xml │ │ ├── playlist_play.xml │ │ ├── queue_music.xml │ │ ├── radio.xml │ │ ├── radio_button_checked.xml │ │ ├── radio_button_unchecked.xml │ │ ├── refresh.xml │ │ ├── remove.xml │ │ ├── repeat.xml │ │ ├── repeat_on.xml │ │ ├── repeat_one.xml │ │ ├── repeat_one_on.xml │ │ ├── replay.xml │ │ ├── restore.xml │ │ ├── screenshot.xml │ │ ├── search.xml │ │ ├── search_off.xml │ │ ├── security.xml │ │ ├── select_all.xml │ │ ├── settings.xml │ │ ├── share.xml │ │ ├── shortcut_library.xml │ │ ├── shortcut_search.xml │ │ ├── shuffle.xml │ │ ├── shuffle_on.xml │ │ ├── similar.xml │ │ ├── skip_next.xml │ │ ├── skip_previous.xml │ │ ├── sliders.xml │ │ ├── slow_motion_video.xml │ │ ├── small_icon.xml │ │ ├── speed.xml │ │ ├── star.xml │ │ ├── stats.xml │ │ ├── storage.xml │ │ ├── subscribe.xml │ │ ├── subscribed.xml │ │ ├── swipe.xml │ │ ├── sync.xml │ │ ├── tab.xml │ │ ├── telegram.xml │ │ ├── time_auto.xml │ │ ├── timer.xml │ │ ├── timer_arrow_down.xml │ │ ├── token.xml │ │ ├── translate.xml │ │ ├── trending_up.xml │ │ ├── tune.xml │ │ ├── update.xml │ │ ├── upload.xml │ │ ├── volume_down.xml │ │ ├── volume_mute.xml │ │ ├── volume_off.xml │ │ ├── volume_off_pause.xml │ │ ├── volume_up.xml │ │ ├── warning.xml │ │ ├── widget_background.xml │ │ ├── widget_like_button_bg.xml │ │ ├── widget_mic_button_bg.xml │ │ ├── widget_mic_button_bg_active.xml │ │ ├── widget_mic_pulse_1.xml │ │ ├── widget_mic_pulse_2.xml │ │ ├── widget_mic_pulse_3.xml │ │ ├── widget_mic_pulse_4.xml │ │ ├── widget_mic_pulse_idle.xml │ │ ├── widget_play_button_circular.xml │ │ ├── widget_play_pill_bg.xml │ │ ├── widget_progress_clip.xml │ │ ├── widget_progress_fill.xml │ │ ├── widget_progress_track.xml │ │ ├── widget_turntable_default_art.xml │ │ ├── widget_turntable_nav_bg.xml │ │ ├── widget_turntable_play_bg.xml │ │ └── wifi_proxy.xml │ ├── drawable-night/ │ │ ├── widget_background.xml │ │ ├── widget_play_pill_bg.xml │ │ ├── widget_progress_fill.xml │ │ ├── widget_progress_track.xml │ │ ├── widget_turntable_nav_bg.xml │ │ └── widget_turntable_play_bg.xml │ ├── drawable-night-v31/ │ │ ├── widget_background.xml │ │ ├── widget_play_pill_bg.xml │ │ ├── widget_progress_fill.xml │ │ ├── widget_progress_track.xml │ │ ├── widget_turntable_nav_bg.xml │ │ └── widget_turntable_play_bg.xml │ ├── drawable-v31/ │ │ ├── ic_launcher_background_v31.xml │ │ ├── ic_widget_mic.xml │ │ ├── widget_background.xml │ │ ├── widget_mic_button_bg.xml │ │ ├── widget_mic_button_bg_active.xml │ │ ├── widget_mic_pulse_1.xml │ │ ├── widget_mic_pulse_2.xml │ │ ├── widget_mic_pulse_3.xml │ │ ├── widget_mic_pulse_4.xml │ │ ├── widget_play_pill_bg.xml │ │ ├── widget_progress_fill.xml │ │ ├── widget_progress_track.xml │ │ ├── widget_turntable_nav_bg.xml │ │ └── widget_turntable_play_bg.xml │ ├── font/ │ │ └── bbh_bartle.xml │ ├── layout/ │ │ ├── widget_compact_square.xml │ │ ├── widget_compact_wide.xml │ │ ├── widget_music_player.xml │ │ ├── widget_recognizer_compact.xml │ │ ├── widget_recognizer_tiny.xml │ │ ├── widget_recognizer_wide.xml │ │ └── widget_turntable.xml │ ├── mipmap-anydpi/ │ │ ├── ic_launcher.xml │ │ ├── ic_launcher_round.xml │ │ ├── ic_launcher_static.xml │ │ └── ic_launcher_static_round.xml │ ├── mipmap-anydpi-v31/ │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ ├── resources.properties │ ├── values/ │ │ ├── app_name.xml │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── metrolist_strings.xml │ │ ├── strings.xml │ │ ├── styles.xml │ │ ├── values.xml │ │ └── widget_colors.xml │ ├── values-ar/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-as/ │ │ └── metrolist_strings.xml │ ├── values-az/ │ │ └── metrolist_strings.xml │ ├── values-b+sr+Latn/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-be/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-bg/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-bn/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-bn-rIN/ │ │ └── strings.xml │ ├── values-bs/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-ca/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-cs/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-de/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-el/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-en-rCA/ │ │ └── strings.xml │ ├── values-es/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-es-rUS/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-et/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-eu/ │ │ └── metrolist_strings.xml │ ├── values-fa/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-fi/ │ │ └── strings.xml │ ├── values-fil/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-fr/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-hi/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-hr/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-hu/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-in/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-it/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-iw/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-ja/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-km/ │ │ └── metrolist_strings.xml │ ├── values-ko/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-lt/ │ │ └── metrolist_strings.xml │ ├── values-mfe/ │ │ └── metrolist_strings.xml │ ├── values-ml/ │ │ └── strings.xml │ ├── values-ms/ │ │ └── metrolist_strings.xml │ ├── values-nb-rNO/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-night/ │ │ ├── colors.xml │ │ └── widget_colors.xml │ ├── values-night-v31/ │ │ └── widget_colors.xml │ ├── values-nl/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-nn/ │ │ └── metrolist_strings.xml │ ├── values-or/ │ │ └── metrolist_strings.xml │ ├── values-pa/ │ │ └── strings.xml │ ├── values-pl/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-pt/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-pt-rBR/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-ro/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-ru/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-sk/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-sl/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-sv/ │ │ └── metrolist_strings.xml │ ├── values-ta/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-te/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-th/ │ │ └── metrolist_strings.xml │ ├── values-tr/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-uk/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-v31/ │ │ ├── styles.xml │ │ └── widget_colors.xml │ ├── values-vi/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-wae/ │ │ └── metrolist_strings.xml │ ├── values-zh-rCN/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── values-zh-rTW/ │ │ ├── metrolist_strings.xml │ │ └── strings.xml │ ├── xml/ │ │ ├── automotive_app_desc.xml │ │ ├── backup_rules.xml │ │ ├── data_extraction_rules.xml │ │ ├── music_widget_info.xml │ │ ├── network_security_config.xml │ │ ├── provider_paths.xml │ │ ├── recognizer_widget_info.xml │ │ ├── shortcuts.xml │ │ └── turntable_widget_info.xml │ └── xml-v31/ │ ├── music_widget_info.xml │ ├── recognizer_widget_info.xml │ └── turntable_widget_info.xml ├── betterlyrics/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── com/ │ └── metrolist/ │ └── music/ │ └── betterlyrics/ │ ├── BetterLyrics.kt │ ├── TTMLParser.kt │ └── models/ │ └── Track.kt ├── build.gradle.kts ├── changelog.md ├── crowdin.yml ├── development_guide.md ├── fastlane/ │ └── metadata/ │ └── android/ │ ├── ar/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── az-AZ/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── bg/ │ │ └── short_description.txt │ ├── ca/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── cs-CZ/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── de-DE/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── en-US/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── es/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── et/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── eu-ES/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── fil/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── fr-FR/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── id/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── it/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ko-KR/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── lt/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── mfe/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── pt/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── pt-BR/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ro/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ru-RU/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── sk/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── sl/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── te-IN/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── tr/ │ │ ├── full_description.txt │ │ └── short_description.txt │ └── uk-UA/ │ ├── full_description.txt │ └── short_description.txt ├── gradle/ │ ├── gradle-daemon-jvm.properties │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── innertube/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── com/ │ └── metrolist/ │ └── innertube/ │ ├── InnerTube.kt │ ├── NetworkConfig.kt │ ├── YouTube.kt │ ├── YouTubeConstants.kt │ ├── models/ │ │ ├── AccountInfo.kt │ │ ├── AutomixPreviewVideoRenderer.kt │ │ ├── Badges.kt │ │ ├── Button.kt │ │ ├── Context.kt │ │ ├── Continuation.kt │ │ ├── ContinuationItemRenderer.kt │ │ ├── Endpoint.kt │ │ ├── GridRenderer.kt │ │ ├── Icon.kt │ │ ├── MediaInfo.kt │ │ ├── Menu.kt │ │ ├── MusicCardShelfRenderer.kt │ │ ├── MusicCarouselShelfRenderer.kt │ │ ├── MusicDescriptionShelfRenderer.kt │ │ ├── MusicEditablePlaylistDetailHeaderRenderer.kt │ │ ├── MusicMultiRowImageItemRenderer.kt │ │ ├── MusicMultiRowListItemRenderer.kt │ │ ├── MusicNavigationButtonRenderer.kt │ │ ├── MusicPlaylistShelfRenderer.kt │ │ ├── MusicQueueRenderer.kt │ │ ├── MusicResponsiveHeaderRenderer.kt │ │ ├── MusicResponsiveListItemRenderer.kt │ │ ├── MusicShelfRenderer.kt │ │ ├── MusicTwoRowItemRenderer.kt │ │ ├── NavigationEndpoint.kt │ │ ├── PlaylistDeleteBody.kt │ │ ├── PlaylistPanelRenderer.kt │ │ ├── PlaylistPanelVideoRenderer.kt │ │ ├── ResponseContext.kt │ │ ├── ReturnYouTubeDislikeResponse.kt │ │ ├── Runs.kt │ │ ├── SearchSuggestions.kt │ │ ├── SearchSuggestionsSectionRenderer.kt │ │ ├── SectionListRenderer.kt │ │ ├── SubscriptionButton.kt │ │ ├── Tabs.kt │ │ ├── TasteProfile.kt │ │ ├── ThumbnailRenderer.kt │ │ ├── Thumbnails.kt │ │ ├── TwoColumnBrowseResultsRenderer.kt │ │ ├── UrlEndpoint.kt │ │ ├── YTItem.kt │ │ ├── YouTubeClient.kt │ │ ├── YouTubeDataPage.kt │ │ ├── YouTubeLocale.kt │ │ ├── body/ │ │ │ ├── AccountMenuBody.kt │ │ │ ├── BrowseBody.kt │ │ │ ├── CreatePlaylistBody.kt │ │ │ ├── EditPlaylistBody.kt │ │ │ ├── FeedbackBody.kt │ │ │ ├── GetQueueBody.kt │ │ │ ├── GetSearchSuggestionsBody.kt │ │ │ ├── GetTranscriptBody.kt │ │ │ ├── LikeBody.kt │ │ │ ├── NextBody.kt │ │ │ ├── PlayerBody.kt │ │ │ ├── SearchBody.kt │ │ │ └── SubscribeBody.kt │ │ └── response/ │ │ ├── AccountMenuResponse.kt │ │ ├── AddItemYouTubePlaylistResponse.kt │ │ ├── BrowseResponse.kt │ │ ├── ContinuationResponse.kt │ │ ├── CreatePlaylistResponse.kt │ │ ├── EditPlaylistResponse.kt │ │ ├── FeedbackResponse.kt │ │ ├── GetQueueResponse.kt │ │ ├── GetSearchSuggestionsResponse.kt │ │ ├── GetTranscriptResponse.kt │ │ ├── ImageUploadResponse.kt │ │ ├── NextResponse.kt │ │ ├── PlayerResponse.kt │ │ └── SearchResponse.kt │ ├── pages/ │ │ ├── AlbumPage.kt │ │ ├── ArtistItemsContinuationPage.kt │ │ ├── ArtistItemsPage.kt │ │ ├── ArtistPage.kt │ │ ├── BrowseResult.kt │ │ ├── ChartsPage.kt │ │ ├── ExplorePage.kt │ │ ├── HistoryPage.kt │ │ ├── HomePage.kt │ │ ├── LibraryAlbumsPage.kt │ │ ├── LibraryContinuationPage.kt │ │ ├── LibraryPage.kt │ │ ├── MoodAndGenres.kt │ │ ├── NewPipe.kt │ │ ├── NewReleaseAlbumPage.kt │ │ ├── NextPage.kt │ │ ├── PageHelper.kt │ │ ├── PlaylistContinuationPage.kt │ │ ├── PlaylistPage.kt │ │ ├── PodcastPage.kt │ │ ├── RelatedPage.kt │ │ ├── SearchPage.kt │ │ ├── SearchSuggestionPage.kt │ │ └── SearchSummaryPage.kt │ └── utils/ │ └── Utils.kt ├── kizzy/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── com/ │ └── my/ │ └── kizzy/ │ ├── gateway/ │ │ ├── DiscordWebSocket.kt │ │ └── entities/ │ │ ├── HeartBeat.kt │ │ ├── Identify.kt │ │ ├── Payload.kt │ │ ├── Ready.kt │ │ ├── Resume.kt │ │ ├── op/ │ │ │ ├── OpCode.kt │ │ │ └── OpCodesSerializer.kt │ │ └── presence/ │ │ ├── Activity.kt │ │ ├── Assets.kt │ │ ├── Metadata.kt │ │ ├── Presence.kt │ │ └── Timestamps.kt │ ├── rpc/ │ │ ├── ArtworkCache.kt │ │ ├── ExternalAssets.kt │ │ ├── KizzyRPC.kt │ │ ├── RpcImage.kt │ │ └── UserInfo.kt │ └── utils/ │ └── Ext.kt ├── kugou/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── com/ │ └── metrolist/ │ └── kugou/ │ ├── KuGou.kt │ └── models/ │ ├── DownloadLyricsResponse.kt │ ├── Keyword.kt │ ├── SearchLyricsResponse.kt │ └── SearchSongResponse.kt ├── lastfm/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── com/ │ └── metrolist/ │ └── lastfm/ │ ├── LastFM.kt │ └── models/ │ └── Authentication.kt ├── lint.xml ├── lrclib/ │ ├── .gitignore │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── com/ │ └── metrolist/ │ └── lrclib/ │ ├── LrcLib.kt │ └── models/ │ └── Track.kt ├── renovate.json ├── settings.gradle.kts ├── shazamkit/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ └── kotlin/ │ └── com/ │ └── metrolist/ │ └── shazamkit/ │ ├── Shazam.kt │ └── models/ │ └── ShazamModels.kt └── simpmusic/ ├── build.gradle.kts └── src/ └── main/ ├── AndroidManifest.xml └── kotlin/ └── com/ └── metrolist/ └── simpmusic/ ├── SimpMusicLyrics.kt └── models/ └── LyricsResponse.kt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug report description: Create a bug report to help us improve labels: [bug] body: - type: markdown attributes: value: | ## IMPORTANT WARNING **Duplicate or incomplete issues will be closed immediately without any response from developers.** - Check existing issues before opening a new one - All required checkboxes below must be checked - Duplicate issues will be closed instantly with no response - type: checkboxes id: checklist attributes: label: "Checklist - Required" description: | **READ CAREFULLY:** You must complete these checks before submitting. Failing to do so will result in your issue being closed immediately without any response from developers. **Duplicate issues = Instant closure with no response** options: - label: " I am able to reproduce the bug with the [latest debug version](https://github.com/MetrolistGroup/Metrolist/actions/workflows/build.yml?query=branch%3Amain)." required: true - label: " I've checked that there is NO open or closed issue about this bug. (Duplicate issues will be closed immediately without response)" required: true - label: " This issue contains only ONE bug. PLEASE check pinned issues before opening a new one." required: true - label: " The title of this issue accurately describes the bug." required: true - label: " The entire issue report is written/translated in English." required: true - type: markdown attributes: value: | --- ## Bug Details - type: textarea id: reproduce-steps attributes: label: Steps to reproduce the bug description: What did you do for the bug to show up? placeholder: | Example: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error validations: required: true - type: textarea id: expected-behavior attributes: label: Expected behavior placeholder: | Example: "This should happen..." validations: required: true - type: textarea id: actual-behavior attributes: label: Actual behavior placeholder: | Example: "This happened instead..." validations: required: true - type: textarea id: sreen-media attributes: label: Screenshots/Screen recordings description: | A picture or video helps us understand the bug more. You can upload them directly in the text box. - type: markdown attributes: value: | ## How to create a logcat recording ### Firstly, download and install these apps - [F0x1d/LogFox](https://github.com/F0x1d/LogFox/releases/download/v2.1.9-78/LogFox-2.1.9-release.apk) - [thedjchi/Shizuku](https://github.com/thedjchi/Shizuku/releases/download/v13.6.0.r1318-thedjchi/shizuku-v13.6.0.r1318-thedjchi.apk) ### Then follow these instructions 1. Open **Shizuku**, and click on the "View command" button 2. Copy the command and run it on your computer (see ADB setup below if needed) 3. Once Shizuku is running, open **LogFox** and start a recording 4. Open the app and reproduce the bug you're experiencing 5. When finished, press **Export** in LogFox and attach the file to your comment 6. Stop the process by pressing **Exit** in the LogFox notification 7. Stop Shizuku (unless you're using it for other apps) ### Or the video guide https://github.com/user-attachments/assets/30659a03-39d5-4400-b681-4238c608aebd > [!IMPORTANT] > **If you're reporting an immediate crash** issue, you should allow LogFox notification permissions on launch. Then open the app and look for a crash report notification from LogFox. > [!TIP] > If you export the recording as a zip file, upload it to https://litterbox.catbox.moe (set expiration to 3 days). - type: textarea id: logs attributes: label: Logs description: | Please read the instructions above on how to create a logcat recording. Logs are crucial for us to understand and fix the bug. Issues without logs might be closed immediately. validations: required: true - type: input id: app-version attributes: label: Metrolist version description: | You can find your Metrolist version in **Settings**. placeholder: | Example: "13.1.1" validations: required: true - type: input id: android-version attributes: label: Android version description: | You can find this in Android Settings > About Phone. placeholder: | Example: "Android 12" validations: required: true - type: textarea id: additional-information attributes: label: Additional information placeholder: | Additional details and attachments. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature request description: Suggest an idea for Metrolist labels: [enhancement] body: - type: checkboxes id: checklist attributes: label: Checklist description: You should ensure the completion of the task before proceeding to check it off the checklist. Neglecting to do so may impede the efficiency of the issue resolution process. The developer has the right to delete the issue directly if you check the list blindly. options: - label: I've checked that there is no other issue about this feature request. required: true - label: This issue contains only one feature request. required: true - label: The title of this issue accurately describes the feature request. required: true - type: textarea id: feature-description attributes: label: Feature description description: What feature you want the app to have? Provide detailed description about what it should look like or where it should be added. validations: required: true - type: textarea id: why-is-the-feature-requested attributes: label: Why do you want this feature? description: Describe the problem or limitation that motivates you to want this feature to be added. validations: required: true - type: textarea id: additional-information attributes: label: Additional information description: Add any other context or screenshots about the feature request here. placeholder: | Additional details and attachments. ================================================ FILE: .github/actions/setup-protobuf/action.yml ================================================ name: Setup and Generate Protobuf description: Install protoc and generate protobuf files runs: using: composite steps: - name: Install protoc run: | sudo apt-get update sudo apt-get install -y protobuf-compiler shell: bash - name: Generate protobuf files run: | cd app bash generate_proto.sh shell: bash ================================================ FILE: .github/pull_request_template.md ================================================ ## Problem ## Cause ## Solution - ## Testing ## Related Issues - Closes # - Related to # ================================================ FILE: .github/scripts/parse_changelog.sh ================================================ #!/usr/bin/env bash # parse_changelog.sh — Extract the changelog entry for a specific version # # Reads changelog.md (or a custom file) and outputs the content block # associated with the given version, as delimited by ---vX.Y.Z separators. # # Usage: # ./parse_changelog.sh [changelog_file] # # Examples: # ./parse_changelog.sh 13.2.1 # ./parse_changelog.sh v13.2.1 changelog.md # ./parse_changelog.sh 13.2.1 /path/to/changelog.md # # Exit codes: # 0 — Version found; content written to stdout # 1 — Error (missing args, file not found, version not found) set -euo pipefail VERSION="${1:-}" CHANGELOG_FILE="${2:-changelog.md}" if [ -z "$VERSION" ]; then echo "Error: version argument required" >&2 echo "Usage: $0 [changelog_file]" >&2 echo "Example: $0 13.2.1" >&2 exit 1 fi if [ ! -f "$CHANGELOG_FILE" ]; then echo "Error: changelog file not found: '$CHANGELOG_FILE'" >&2 exit 1 fi VERSION="${VERSION#v}" if ! grep -q "^---v${VERSION}$" "$CHANGELOG_FILE"; then echo "Error: version '$VERSION' not found in '$CHANGELOG_FILE'" >&2 exit 1 fi awk -v ver="$VERSION" ' /^---v/ { if (found) exit if ($0 == "---v" ver) { found=1; next } next } found { print } ' "$CHANGELOG_FILE" ================================================ FILE: .github/workflows/build.yml ================================================ name: Build APKs on: workflow_dispatch: push: branches: ["**"] paths-ignore: - "README.md" - "fastlane/**" - "assets/**" - ".github/**/*.md" - ".github/FUNDING.yml" - ".github/ISSUE_TEMPLATE/**" permissions: contents: write discussions: write jobs: build_release: name: Build Release (${{ matrix.variant }}) if: github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' runs-on: ubuntu-latest strategy: matrix: include: - variant: foss variantName: Foss - variant: gms variantName: Gms steps: - uses: actions/checkout@v6 with: submodules: recursive - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: "21" distribution: "temurin" - name: Set Up Gradle uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_CACHE_KEY }} cache-cleanup: on-success - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build Release APK and Run Lint run: ./gradlew --console=plain assemble${{ matrix.variantName }}Release :app:lint${{ matrix.variantName }}Release --warning-mode summary env: METROLIST_APPLICATION_ID: com.metrolist.music METROLIST_APP_NAME: Metrolist Nightly PULL_REQUEST: "false" GITHUB_EVENT_NAME: ${{ github.event_name }} LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} - name: Sign APK uses: ilharp/sign-android-release@v2.0.0 with: releaseDir: app/build/outputs/apk/${{ matrix.variant }}/release/ signingKey: ${{ secrets.KEYSTORE }} keyAlias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} buildToolsVersion: 35.0.0 - name: Move signed APK run: | OUTPUT_DIR="app/build/outputs/apk/${{ matrix.variant }}/release" mkdir -p "$OUTPUT_DIR/out" if [ "${{ matrix.variant }}" = "gms" ]; then TARGET_NAME="app-universal-with-Google-Cast.apk" else TARGET_NAME="app-universal-release.apk" fi find "$OUTPUT_DIR" -name "*-signed.apk" -o -name "*-unsigned-signed.apk" | xargs -I{} mv {} "$OUTPUT_DIR/out/$TARGET_NAME" - name: Upload Signed APK uses: actions/upload-artifact@v6 with: name: ${{ matrix.variant == 'gms' && 'app-with-Google-Cast' || 'app-release' }} path: app/build/outputs/apk/${{ matrix.variant }}/release/out/* build_debug: name: Build Debug (${{ matrix.variant }}) if: github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]' runs-on: ubuntu-latest strategy: matrix: include: - variant: foss variantName: Foss - variant: gms variantName: Gms steps: - uses: actions/checkout@v6 with: submodules: recursive - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: "21" distribution: "temurin" - name: Set Up Gradle uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_CACHE_KEY }} cache-cleanup: on-success - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Restore Persistent Keystore run: | echo "${{ secrets.DEBUG_KEYSTORE }}" | base64 -d > ./app/persistent-debug.keystore - name: Build Debug APK and Run Lint run: ./gradlew --console=plain assemble${{ matrix.variantName }}Debug :app:lint${{ matrix.variantName }}Debug --warning-mode summary env: METROLIST_APPLICATION_ID: com.metrolist.music METROLIST_APP_NAME: Metrolist Nightly PULL_REQUEST: "false" GITHUB_EVENT_NAME: ${{ github.event_name }} LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} - name: Upload Debug APK uses: actions/upload-artifact@v6 with: name: ${{ matrix.variant == 'gms' && 'app-debug-gms' || 'app-debug' }} path: app/build/outputs/apk/${{ matrix.variant }}/debug/*.apk ================================================ FILE: .github/workflows/build_pr.yml ================================================ name: Build PR on: pull_request: branches: - "**" jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: submodules: recursive - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: 21 distribution: "temurin" - name: Set Up Gradle uses: gradle/actions/setup-gradle@v5 with: cache-read-only: true cache-cleanup: on-success - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Restore PR debug keystore id: pr_keystore uses: actions/cache/restore@v4 with: path: app/pr-debug.keystore key: pr-debug-keystore-${{ github.event.number }} - name: Generate PR debug keystore if not cached if: steps.pr_keystore.outputs.cache-hit != 'true' run: | keytool -genkeypair -v \ -keystore app/pr-debug.keystore \ -storepass android \ -alias androiddebugkey \ -keypass android \ -keyalg RSA \ -keysize 2048 \ -validity 10000 \ -dname "CN=Metrolist PR ${{ github.event.number }},O=Metrolist,C=US" - name: Save PR debug keystore if: steps.pr_keystore.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: app/pr-debug.keystore key: ${{ steps.pr_keystore.outputs.cache-primary-key }} - name: Build and Lint FOSS Debug APK run: ./gradlew --console=plain assembleFossDebug :app:lintFossDebug --warning-mode summary env: GITHUB_EVENT_NAME: ${{ github.event_name }} METROLIST_APPLICATION_ID: com.metrolist.music.pr.p${{ github.event.number }} METROLIST_APP_NAME: Metrolist PR ${{ github.event.number }} METROLIST_DEBUG_KEYSTORE_PATH: pr-debug.keystore PULL_REQUEST: "true" LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} - name: Upload APK uses: actions/upload-artifact@v6 with: name: app-universal-debug-pr-${{ github.event.number }} path: app/build/outputs/apk/foss/debug/*.apk ================================================ FILE: .github/workflows/build_quick.yml ================================================ name: Quick Test Build on: workflow_dispatch: push: branches: ["**"] paths-ignore: - "README.md" - "fastlane/**" - "assets/**" - ".github/**/*.md" - ".github/FUNDING.yml" - ".github/ISSUE_TEMPLATE/**" permissions: contents: write jobs: build_quick: name: Quick Universal Release Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: submodules: recursive - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: "21" distribution: "temurin" - name: Set Up Gradle uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_CACHE_KEY }} cache-cleanup: on-success - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build Release APK (No Lint) run: ./gradlew --console=plain assembleFossRelease --warning-mode summary -x lint -x lintFossRelease env: METROLIST_APPLICATION_ID: com.metrolist.music.test METROLIST_APP_NAME: Metrolist Testing PULL_REQUEST: "false" GITHUB_EVENT_NAME: ${{ github.event_name }} LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} - name: Sign APK uses: ilharp/sign-android-release@v2.0.0 with: releaseDir: app/build/outputs/apk/foss/release/ signingKey: ${{ secrets.KEYSTORE }} keyAlias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} buildToolsVersion: 35.0.0 - name: Move signed APK run: | OUTPUT_DIR="app/build/outputs/apk/foss/release" mkdir -p "$OUTPUT_DIR/out" find "$OUTPUT_DIR" -name "*-signed.apk" -o -name "*-unsigned-signed.apk" | xargs -I{} mv {} "$OUTPUT_DIR/out/app-universal-test-release.apk" - name: Upload Signed APK uses: actions/upload-artifact@v6 with: name: app-universal-test-release path: app/build/outputs/apk/foss/release/out/* ================================================ FILE: .github/workflows/release.yml ================================================ name: Bump to new version on: push: branches: - "main" paths: - "app/build.gradle.kts" workflow_dispatch: jobs: check-version: if: "!contains(github.ref, 'refs/tags')" runs-on: ubuntu-latest outputs: version_changed: ${{ steps.check_version.outputs.version_changed }} new_version: ${{ steps.check_version.outputs.new_version }} version_code: ${{ steps.check_version.outputs.version_code }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 submodules: recursive - name: Check if version changed id: check_version run: | if [ ! -f app/build.gradle.kts ]; then echo "File app/build.gradle.kts does not exist" echo "version_changed=false" >> $GITHUB_OUTPUT exit 0 fi NEW_VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts || echo "") VERSION_CODE=$(grep -oP 'versionCode\s*=\s*\K\d+' app/build.gradle.kts || echo "") if [ -z "$NEW_VERSION" ]; then echo "Could not find versionName in app/build.gradle.kts" echo "version_changed=false" >> $GITHUB_OUTPUT exit 0 fi echo "Current version: $NEW_VERSION (code: $VERSION_CODE)" if git show HEAD^:app/build.gradle.kts > /dev/null 2>&1; then OLD_VERSION=$(git show HEAD^:app/build.gradle.kts | grep -oP 'versionName\s*=\s*"\K[^"]+' || echo "") echo "Previous version: $OLD_VERSION" if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then echo "Version changed from $OLD_VERSION to $NEW_VERSION" echo "version_changed=true" >> $GITHUB_OUTPUT echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT else echo "Version unchanged: $NEW_VERSION" echo "version_changed=false" >> $GITHUB_OUTPUT fi else echo "First version detected: $NEW_VERSION" echo "version_changed=true" >> $GITHUB_OUTPUT echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT fi - name: Debug output run: | echo "Debug Information:" echo " - version_changed: ${{ steps.check_version.outputs.version_changed }}" echo " - new_version: ${{ steps.check_version.outputs.new_version }}" echo " - version_code: ${{ steps.check_version.outputs.version_code }}" echo " - event_name: ${{ github.event_name }}" build: needs: check-version if: needs.check-version.outputs.version_changed == 'true' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest strategy: matrix: include: - variant: foss variantName: Foss - variant: gms variantName: Gms - variant: izzy variantName: Izzy steps: - uses: actions/checkout@v6 with: submodules: recursive - name: Set up JDK 21 uses: actions/setup-java@v5 with: java-version: "21" distribution: "temurin" - name: Set Up Gradle uses: gradle/actions/setup-gradle@v5 with: cache-disabled: true cache-cleanup: on-success - name: Setup and Generate Protobuf uses: ./.github/actions/setup-protobuf - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build and Lint Release APK run: ./gradlew --no-configuration-cache --console=plain clean assemble${{ matrix.variantName }}Release :app:lint${{ matrix.variantName }}Release --warning-mode summary env: METROLIST_APPLICATION_ID: com.metrolist.music METROLIST_APP_NAME: Metrolist PULL_REQUEST: "false" GITHUB_EVENT_NAME: ${{ github.event_name }} LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} - name: Sign APK uses: ilharp/sign-android-release@v2.0.0 with: releaseDir: app/build/outputs/apk/${{ matrix.variant }}/release/ signingKey: ${{ secrets.KEYSTORE }} keyAlias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} buildToolsVersion: 35.0.0 - name: Move and rename signed APKs run: | OUTPUT_DIR="app/build/outputs/apk/${{ matrix.variant }}/release" mkdir -p "$OUTPUT_DIR/out" SIGNED_APK=$(find "$OUTPUT_DIR" -name "*-signed.apk" -o -name "*-unsigned-signed.apk" | head -1) if [ -z "$SIGNED_APK" ]; then echo "No signed APK found for ${{ matrix.variant }}" ls -la "$OUTPUT_DIR" exit 1 fi if [ "${{ matrix.variant }}" = "gms" ]; then TARGET_NAME="Metrolist-with-Google-Cast.apk" elif [ "${{ matrix.variant }}" = "izzy" ]; then TARGET_NAME="Metrolist-izzy.apk" else TARGET_NAME="Metrolist.apk" fi mv "$SIGNED_APK" "$OUTPUT_DIR/out/$TARGET_NAME" echo "APK renamed to: $TARGET_NAME" ls -la "$OUTPUT_DIR/out/" - name: Upload Signed APKs uses: actions/upload-artifact@v6 with: name: ${{ matrix.variant == 'gms' && 'Metrolist-with-Google-Cast' || matrix.variant == 'izzy' && 'Metrolist-izzy' || 'Metrolist' }} path: app/build/outputs/apk/${{ matrix.variant }}/release/out/*.apk create-release: needs: [check-version, build] if: needs.check-version.outputs.version_changed == 'true' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Download all APKs uses: actions/download-artifact@v7 with: path: downloaded_artifacts/ - name: Organize APKs for release run: | echo "Organizing APK files..." mkdir -p release_files find downloaded_artifacts -name "*.apk" -exec cp {} release_files/ \; echo "APKs ready for release:" ls -la release_files/ - name: Parse release notes from changelog run: | VERSION="${{ needs.check-version.outputs.new_version }}" chmod +x .github/scripts/parse_changelog.sh if bash .github/scripts/parse_changelog.sh "$VERSION" changelog.md > release_notes.md; then echo "Release notes sourced from changelog.md for v$VERSION" echo "--- Preview (first 20 lines) ---" head -20 release_notes.md else echo "::warning::v$VERSION not found in changelog.md — falling back to git log" { echo "Release of version $VERSION" echo "" git log "$(git describe --tags --abbrev=0 2>/dev/null || echo "HEAD~10")..HEAD" \ --pretty=format:"- %s" --no-merges | head -10 || echo "- Initial release" } > release_notes.md echo "--- Fallback notes ---" cat release_notes.md fi - name: Create Release env: GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | echo "Creating release v${{ needs.check-version.outputs.new_version }}" gh release create "v${{ needs.check-version.outputs.new_version }}" \ --title "${{ needs.check-version.outputs.new_version }}" \ --notes-file release_notes.md \ --latest \ release_files/*.apk echo "Release created successfully!" - name: Update release summary run: | echo "## Release Summary" >> $GITHUB_STEP_SUMMARY echo "- **Version**: v${{ needs.check-version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY echo "- **Version Code**: ${{ needs.check-version.outputs.version_code }}" >> $GITHUB_STEP_SUMMARY echo "- **APKs Built**: $(ls release_files/*.apk | wc -l)" >> $GITHUB_STEP_SUMMARY echo "- **Release URL**: https://github.com/${{ github.repository }}/releases/tag/v${{ needs.check-version.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY ================================================ FILE: .gitignore ================================================ # Built application files *.apk *.aar *.ap_ *.aab # Files for the ART/Dalvik VM *.dex # Java class files *.class # Generated files bin/ gen/ out/ # Uncomment the following line in case you need and you don't have the release build type files in your app # release/ # Gradle files .gradle/ build/ # Local configuration file (sdk path, etc) local.properties # Proguard folder generated by Eclipse proguard/ # Log Files *.log # Android Studio Navigation editor temp files .navigation/ # Android Studio captures folder captures/ # IntelliJ *.iml .idea .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 app/persistent-debug.keystore # ^^^ think twice before removing this gitignore, it became so FUCKING # annoying to unstage this file every time i want to commit stuff # 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 # VS Code .vscode/ # Binary files and core dumps core core.* *.so *.bin .env app/src/main/java/com/metrolist/music/listentogether/proto/* # FFTW third-party library build artifacts .build-fftw app/src/main/cpp/coverart/ ================================================ FILE: .gitmodules ================================================ [submodule "metroproto"] path = metroproto url = https://github.com/MetrolistGroup/metroproto ================================================ FILE: AGENTS.md ================================================ # Working with Metrolist as an AI agent Metrolist is a 3rd party YouTube Music client written in Kotlin. It follows material 3 design guidelines closely. ## Rules for working on the project 1. Always create a new branch for your feature work. Follow these naming conventions: - Bug fixes: `fix/short-description` - New features: `feat/short-description` - Refactoring: `ref/short-description` - Documentation: `docs/short-description` - Chores: `chore/short-description` 2. Branch descriptions should be concise yet descriptive enough to understand the purpose of the branch at a glance. 3. Always pull the latest changes from `main` before starting your work to minimize merge conflicts. 4. While working on your feature you should rebase your branch on top of the latest `main` at least once a day to ensure compatibility. 5. Commit names should be clear and follow the format: `type(scope): short description`. For example: `feat(ui): add dark mode support`. Including the scope is optional. 6. All string edits should be made to the `Metrolist/app/src/main/res/values/metrolist_strings.xml` file, NOT `Metrolist/app/src/main/res/values/strings.xml`. Do not touch other `strings.xml` or `metrolist_strings.xml` files in the project. 7. You are to follow best practices for Kotlin and Android development. ## AI-only guidelines 1. You are strictly prohibited from making ANY changes to the readme/markdown files, including this one. This is to ensure that the documentation remains accurate and consistent for all contributors. 2. You are NOT allowed to use the following commands: - You are not to commit, push, or merge any changes to any branch. - You should absolutely NOT use any commands that would modify the git history, do force pushes (except for rebases on your own branch), or delete branches without explicit instructions from a human. 3. Always follow the guidelines and instructions provided by human contributors. 4. Ensure the absolutely highest code quality in all contributions, including proper formatting, clear variable naming, and comprehensive comments where necessary. 5. Comments should be added only for complex logic or non-obvious code. Avoid redundant comments that simply restate what the code does. 6. Prioritize performance, battery efficiency, and maintainability in all code contributions. Always consider the impact of your changes on the overall user experience and app performance. 7. If you have any doubts ask a human contributor. Never make assumptions about the requirements or implementation details without clarification. 8. If you do not test your changes using the instructions in the next section, you will be faced with reprimands from human contributors and may be asked to redo your work. Always ensure that you test your changes thoroughly before asking for a final review. 9. You are absolutely **not allowed to bump the version** of the app in ANY way. Version bumps are only done by the core development team after manual review. ## Building and testing your changes 1. After making changes to the code, you should build the app to ensure that there are no compilation errors. Use the following command from the root directory of the project: ```bash ./gradlew :app:assembleFossDebug ``` 2. If the build is not successful, review the error messages, fix the issues in your code, and try building again. 3. Once the build is successful, you can test your changes on an emulator or a physical device. Install the generated APK located at `app/build/outputs/apk/universalFoss/debug/app-universal-foss-debug.apk` and ask a human for help testing the specific features you worked on. ================================================ FILE: LICENSE ================================================ 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 . ================================================ FILE: README.md ================================================

Metrolist

YouTube Music client for Android

⚠Warning

If you're in a region where YouTube Music is not supported, you won't be able to use this app unless you have a proxy or VPN to connect to a YTM-supported region.

Screenshots

Release numbers

[![Latest release](https://img.shields.io/github/v/release/MetrolistGroup/Metrolist?style=for-the-badge)](https://github.com/MetrolistGroup/Metrolist/releases) [![GitHub license](https://img.shields.io/github/license/MetrolistGroup/metrolist?style=for-the-badge)](https://github.com/MetrolistGroup/Metrolist/blob/main/LICENSE) [![Downloads](https://img.shields.io/github/downloads/MetrolistGroup/Metrolist/total?style=for-the-badge)](https://github.com/MetrolistGroup/Metrolist/releases)

Download Now

Get it on GitHub
Get it on OpenAPK
Get it on Obtainium Get it on IzzyOnDroid
Get it on Belberi

Nightly Build

Get it on GitHub

Table of Contents

- [Features](#features) - [FAQ](#faq) - [Development Setup](./development_guide.md) - [Translations](#translations) - [Support Me](#support-me) - [Join our community](#join-our-community) - [Contributors](#thanks-to-all-contributors)

Features

- Play any song or video from YT Music - Background playback - Personalized quick picks - Library management - Listen together with friends - Download and cache songs for offline playback - Search for songs, albums, artists, videos and playlists - Live lyrics - YouTube Music account login support - Syncing of songs, artists, albums and playlists, from and to your account - Skip silence - Import playlists - Audio normalization - Adjust tempo/pitch - Local playlist management - Reorder songs in playlist or queue - Home screen widget with playback controls - Light - Dark - black - Dynamic theme - Sleep timer - Material 3 - etc.

Translations

[![Translation status](https://img.shields.io/weblate/progress/metrolist?style=for-the-badge)](https://hosted.weblate.org/engage/metrolist/) We use Weblate to translate Metrolist. For more details or to get started, visit our [Weblate page](https://hosted.weblate.org/projects/Metrolist/). Translation status Thank you very much for helping to make Metrolist accessible to many people worldwide.

FAQ

### Q: Why Metrolist isn't showing in Android Auto? 1. Go to Android Auto's settings and tap multiple times on the version in the bottom to enable developer settings 2. In the three dots menu at the top-right of the screen, click "Developer settings" 3. Enable "Unknown sources"

Support Me

If you'd like to support my work, send a Monero (XMR) donation to this address: 44XjSELSWcgJTZiCKzjpCQWyXhokrH9RqH3rpp35FkSKi57T25hniHWHQNhLeXyFn3DDYqufmfRB1iEtENerZpJc7xJCcqt Or scan this QR code: QR Code Or other Buy Me a Coffee

Join our community

[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white&labelColor=1c1917)](https://dsc.gg/metrolist) [![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white&labelColor=1c1917)](https://t.me/metrolistapp)

Special thanks

**InnerTune** [Zion Huang](https://github.com/z-huang) • [Malopieds](https://github.com/Malopieds) **OuterTune** [Davide Garberi](https://github.com/DD3Boh) • [Michael Zh](https://github.com/mikooomich) Credits: [**Kizzy**](https://github.com/dead8309/Kizzy) – for the Discord Rich Presence implementation and inspiration. [**Better Lyrics**](https://better-lyrics.boidu.dev) – for beautiful time-synced lyrics with word-by-word highlighting, and seamless YouTube Music integration. [**SimpMusic Lyrics**](https://github.com/maxrave-dev/SimpMusic) – for providing lyrics data through the SimpMusic Lyrics API. [**metroserver**](https://github.com/MetrolistGroup/metroserver) – for providing us with the listen together implementation. [**MusicRecognizer**](https://github.com/aleksey-saenko/MusicRecognizer) – for the music recognition feature implementation and Shazam API integration. The open-source community for tools, libraries, and APIs that make this project possible. Thank you to all the amazing developers who made this project possible!

Thanks to all contributors

Disclaimer

This project and its contents are not affiliated with, funded, authorized, endorsed by, or in any way associated with YouTube, Google LLC, Metrolist Group LLC or any of its affiliates and subsidiaries. Any trademark, service mark, trade name, or other intellectual property rights used in this project are owned by the respective owners. **Made with ❤️ by [Mo Agamy](https://github.com/mostafaalagamy)** **This project stands with Palestine 🇵🇸** ================================================ FILE: app/.gitignore ================================================ /build *.keystore src/main/cpp/vibrafp/third_party/fftw-android/ ================================================ FILE: app/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Properties val localProperties = Properties() val localPropertiesFile = rootProject.file("local.properties") if (localPropertiesFile.exists()) { localProperties.load(localPropertiesFile.inputStream()) } val baseApplicationId = "com.metrolist.music" val applicationIdOverride = System.getenv("METROLIST_APPLICATION_ID")?.takeIf { it.isNotBlank() } val appNameOverride = System.getenv("METROLIST_APP_NAME")?.takeIf { it.isNotBlank() } val debugKeystorePathOverride = System.getenv("METROLIST_DEBUG_KEYSTORE_PATH")?.takeIf { it.isNotBlank() } val debugKeystorePassword = System.getenv("METROLIST_DEBUG_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() } ?: "android" val debugKeyAlias = System.getenv("METROLIST_DEBUG_KEY_ALIAS")?.takeIf { it.isNotBlank() } ?: "androiddebugkey" val debugKeyPassword = System.getenv("METROLIST_DEBUG_KEY_PASSWORD")?.takeIf { it.isNotBlank() } ?: "android" val persistentDebugKeystoreFile = file("persistent-debug.keystore") val workflowDebugKeystoreFile = debugKeystorePathOverride?.let(::file) plugins { id("com.android.application") alias(libs.plugins.hilt) alias(libs.plugins.kotlin.ksp) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.serialization) } android { namespace = "com.metrolist.music" compileSdk = 36 defaultConfig { applicationId = applicationIdOverride ?: baseApplicationId minSdk = 26 targetSdk = 36 versionCode = 143 versionName = "13.3.0" resValue("string", "app_name", appNameOverride ?: "Metrolist") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true // LastFM API keys from GitHub Secrets val lastFmKey = localProperties.getProperty("LASTFM_API_KEY") ?: System.getenv("LASTFM_API_KEY") ?: "" val lastFmSecret = localProperties.getProperty("LASTFM_SECRET") ?: System.getenv("LASTFM_SECRET") ?: "" buildConfigField("String", "LASTFM_API_KEY", "\"$lastFmKey\"") buildConfigField("String", "LASTFM_SECRET", "\"$lastFmSecret\"") buildConfigField("String", "ARCHITECTURE", "\"universal\"") } flavorDimensions += listOf("variant") productFlavors { // FOSS variant (default) - F-Droid compatible, no Google Play Services create("foss") { dimension = "variant" isDefault = true buildConfigField("Boolean", "CAST_AVAILABLE", "false") buildConfigField("Boolean", "UPDATER_AVAILABLE", "true") } // GMS variant - with Google Cast support (requires Google Play Services) create("gms") { dimension = "variant" buildConfigField("Boolean", "CAST_AVAILABLE", "true") buildConfigField("Boolean", "UPDATER_AVAILABLE", "true") } // IzzyOnDroid variant - no Google Cast, no built-in updater (store handles updates) create("izzy") { dimension = "variant" buildConfigField("Boolean", "CAST_AVAILABLE", "false") buildConfigField("Boolean", "UPDATER_AVAILABLE", "false") } } signingConfigs { create("persistentDebug") { storeFile = persistentDebugKeystoreFile storePassword = "android" keyAlias = "androiddebugkey" keyPassword = "android" } create("workflowDebug") { storeFile = workflowDebugKeystoreFile ?: persistentDebugKeystoreFile storePassword = debugKeystorePassword keyAlias = debugKeyAlias keyPassword = debugKeyPassword } create("release") { storeFile = file("keystore/release.keystore") storePassword = System.getenv("STORE_PASSWORD") keyAlias = System.getenv("KEY_ALIAS") keyPassword = System.getenv("KEY_PASSWORD") } getByName("debug") { keyAlias = "androiddebugkey" keyPassword = "android" storePassword = "android" storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore") } } buildTypes { release { isMinifyEnabled = true isShrinkResources = true isCrunchPngs = false isDebuggable = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) } debug { if (applicationIdOverride == null) { applicationIdSuffix = ".debug" } isDebuggable = true if (appNameOverride == null) { resValue("string", "app_name", "Metrolist Debug") } signingConfig = if (workflowDebugKeystoreFile != null) { signingConfigs.getByName("workflowDebug") } else if (persistentDebugKeystoreFile.exists()) { signingConfigs.getByName("persistentDebug") } else { signingConfigs.getByName("debug") } } } compileOptions { isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } kotlin { jvmToolchain(21) compilerOptions { freeCompilerArgs.add("-Xannotation-default-target=param-property") jvmTarget.set(JvmTarget.JVM_21) } } buildFeatures { compose = true buildConfig = true resValues = true } dependenciesInfo { includeInApk = false includeInBundle = false } lint { lintConfig = file("lint.xml") warningsAsErrors = false abortOnError = false checkDependencies = false } androidResources { generateLocaleConfig = true } packaging { jniLibs { useLegacyPackaging = false keepDebugSymbols += listOf( "**/libandroidx.graphics.path.so", "**/libdatastore_shared_counter.so", ) } resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "META-INF/NOTICE.md" excludes += "META-INF/CONTRIBUTORS.md" excludes += "META-INF/LICENSE.md" excludes += "META-INF/INDEX.LIST" excludes += "META-INF/io.netty.versions.properties" } } } ksp { arg("room.schemaLocation", "$projectDir/schemas") } tasks.withType().configureEach { compilerOptions { freeCompilerArgs.addAll( "-opt-in=kotlin.RequiresOptIn", ) suppressWarnings.set(false) } } // Android provides org.json as a platform API (/apex/com.android.art/javalib/core-libart.jar). // The standalone org.json:json artefact bundles an older Apache Harmony copy of JSONArray that // contains an internal `myArrayList` field absent from the platform class. Without obfuscation // R8 inlines against this internal field; at runtime the platform class is resolved instead, // producing a NoSuchFieldError. Excluding the artefact globally ensures only the platform // class is ever referenced. configurations.configureEach { exclude(group = "org.json", module = "json") } dependencies { implementation(libs.guava) implementation(libs.coroutines.guava) implementation(libs.concurrent.futures) implementation(libs.activity) 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(libs.materialKolor) implementation(libs.appcompat) implementation(libs.coil) implementation(libs.coil.network.okhttp) implementation(libs.ucrop) implementation(libs.shimmer) implementation(libs.media3) implementation(libs.media3.session) implementation(libs.media3.okhttp) // Google Cast - only included in GMS flavor (not available in F-Droid/FOSS builds) "gmsImplementation"(libs.media3.cast) "gmsImplementation"(libs.mediarouter) "gmsImplementation"(libs.cast.framework) implementation(libs.room.runtime) implementation(libs.kuromoji.ipadic) implementation(libs.tinypinyin) ksp(libs.room.compiler) implementation(libs.room.ktx) implementation(libs.apache.lang3) implementation(libs.hilt) implementation(libs.jsoup) ksp(libs.hilt.compiler) implementation(project(":innertube")) implementation(project(":kugou")) implementation(project(":lrclib")) implementation(project(":kizzy")) implementation(project(":lastfm")) implementation(project(":betterlyrics")) implementation(project(":simpmusic")) implementation(project(":shazamkit")) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.json) // Protobuf for message serialization (lite version for Android) implementation(libs.protobuf.javalite) implementation(libs.protobuf.kotlin.lite) coreLibraryDesugaring(libs.desugaring) implementation(libs.timber) } ================================================ FILE: app/generate_proto.sh ================================================ #!/bin/bash # Generate Kotlin protobuf files for Android set -e PROTO_DIR="../metroproto" OUT_DIR="src/main/java" if [ ! -f "$PROTO_DIR/listentogether.proto" ]; then echo "Missing proto file at $PROTO_DIR/listentogether.proto" echo "Did you initialize submodules? Try: git submodule update --init --recursive" exit 1 fi # Create output directory if it doesn't exist mkdir -p "$OUT_DIR" # Generate Java and Kotlin code (lite version for Android) protoc --java_out=lite:"$OUT_DIR" --kotlin_out="$OUT_DIR" \ -I="$PROTO_DIR" \ "$PROTO_DIR/listentogether.proto" echo "Protobuf files (lite) generated successfully in $OUT_DIR" ================================================ FILE: app/lint.xml ================================================ ================================================ FILE: app/proguard-rules.pro ================================================ # 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 ## Reproducible Build Support # Disable obfuscation to ensure deterministic R8 output across different build environments. # Without this, R8 assigns short names (e.g. `j`, `k`) to renamed classes in a non-deterministic # order, causing byte-for-byte differences between builds. This is required for F-Droid / IzzyOnDroid # Reproducible Build verification. Code shrinking (dead code removal) remains fully enabled. # Since Metrolist is fully open-source, obfuscation provides no meaningful security benefit. -dontobfuscate # WEB_REMIX Streaming - WebView JavaScript interfaces -keepclassmembers class com.metrolist.music.utils.sabr.EjsNTransformSolver$SolverWebView { @android.webkit.JavascriptInterface public *; } -keepclassmembers class com.metrolist.music.utils.cipher.CipherWebView { @android.webkit.JavascriptInterface public *; } -keepclassmembers class com.metrolist.music.utils.potoken.PoTokenWebView { @android.webkit.JavascriptInterface public *; } # Keep streaming utility classes -keep class com.metrolist.music.utils.cipher.** { *; } -keep class com.metrolist.music.utils.sabr.** { *; } -keep class com.metrolist.music.utils.potoken.** { *; } # Keep coroutine continuation for WebView callbacks -keepclassmembers class * { void resume(...); void resumeWithException(...); } ## Kotlin Coroutines — Reproducible Build Rules # Keep volatile fields in coroutine classes to prevent AtomicFieldUpdater optimisation issues # and ensure R8 does not reorder or merge these across builds. # Source: https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/jvm/resources/META-INF/proguard/coroutines.pro -keepclassmembers class kotlinx.coroutines.** { volatile ; } -keepclassmembers class kotlin.coroutines.SafeContinuation { volatile ; } # Eliminate coroutines debug-only code paths so R8 sees a single, consistent # control-flow graph regardless of build machine or JVM configuration. # Source: https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/jvm/resources/META-INF/proguard/r8-from-1.6.0/coroutines.pro -assumenosideeffects class kotlinx.coroutines.internal.MainDispatcherLoader { boolean FAST_SERVICE_LOADER_ENABLED return false; } -assumenosideeffects class kotlinx.coroutines.internal.FastServiceLoaderKt { boolean ANDROID_DETECTED return true; } -assumenosideeffects class kotlinx.coroutines.DebugKt { boolean getASSERTIONS_ENABLED() return false; boolean getDEBUG() return false; boolean getRECOVER_STACK_TRACES() return false; } # 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 *; #} # Preserve line number information for readable crash 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.services.youtube.protos.** { *; } -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 # Keep all classes within the kuromoji package -keep class com.atilika.kuromoji.** { *; } ## Queue Persistence Rules # Keep queue-related classes to prevent serialization issues in release builds -keep class com.metrolist.music.models.PersistQueue { *; } -keep class com.metrolist.music.models.PersistPlayerState { *; } -keep class com.metrolist.music.models.QueueData { *; } -keep class com.metrolist.music.models.QueueType { *; } -keep class com.metrolist.music.playback.queues.** { *; } # Keep serialization methods for queue persistence -keepclassmembers class * implements java.io.Serializable { private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); } ## UCrop Rules -dontwarn com.yalantis.ucrop** -keep class com.yalantis.ucrop** { *; } -keep interface com.yalantis.ucrop** { *; } ## Google Cast Rules -keep class com.metrolist.music.cast.** { *; } -keep class com.google.android.gms.cast.** { *; } -keep class androidx.mediarouter.** { *; } ## JSoup re2j optional dependency -dontwarn com.google.re2j.** # Vibra fingerprint library -keep class com.metrolist.music.recognition.VibraSignature { *; } -keepclassmembers class com.metrolist.music.recognition.VibraSignature { native ; } ## Kotlin Reflection Fix -keep class kotlin.Metadata { *; } -keep class kotlin.reflect.** { *; } -dontwarn kotlin.reflect.** ## Ktor Serialization -keep class io.ktor.** { *; } -keepclassmembers class io.ktor.** { *; } -dontwarn io.ktor.** ## Listen Together Protobuf -keep class com.metrolist.music.listentogether.proto.** { *; } -keepclassmembers class com.metrolist.music.listentogether.proto.** { *; } ## Shazam Models -keep class com.metrolist.shazamkit.models.** { *; } -keepclassmembers class com.metrolist.shazamkit.models.** { *; } ## Kotlinx Serialization -keepattributes *Annotation* -keepclassmembers class com.metrolist.shazamkit.models.** { *** Companion; } -keepclasseswithmembers class com.metrolist.shazamkit.models.** { kotlinx.serialization.KSerializer serializer(...); } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/1.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/10.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/11.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/12.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/13.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/14.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/15.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/16.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/17.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/18.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/19.json ================================================ { "formatVersion": 1, "database": { "version": 19, "identityHash": "c8f37a94d4c749f6a6c07a53f7b2e1fc", "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": "artistName", "columnName": "artistName", "affinity": "TEXT", "notNull": false }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "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, 'c8f37a94d4c749f6a6c07a53f7b2e1fc')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/2.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/20.json ================================================ { "formatVersion": 1, "database": { "version": 20, "identityHash": "d9f47b95e5d749f7b7c08a64f8c3f2fd", "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, `artistName` TEXT, `explicit` INTEGER NOT NULL DEFAULT 0, `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": "artistName", "columnName": "artistName", "affinity": "TEXT", "notNull": false }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `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": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "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, 'd9f47b95e5d749f7b7c08a64f8c3f2fd')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/21.json ================================================ { "formatVersion": 1, "database": { "version": 21, "identityHash": "e0f58c96f6e849f8c8d09b75f9d4f3fe", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, 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": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "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 }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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, `isLocal` INTEGER NOT NULL DEFAULT 0, 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 }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, 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": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "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 }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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, `isLocal` INTEGER NOT NULL DEFAULT 0, 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 }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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, 'e0f58c96f6e849f8c8d09b75f9d4f3fe')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/22.json ================================================ { "formatVersion": 1, "database": { "version": 22, "identityHash": "e0f58c96f6e849f8c8d09b75f9d4f3fe", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, 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": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "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 }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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, `isLocal` INTEGER NOT NULL DEFAULT 0, 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 }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT 0, 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": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "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 }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT 0, 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": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT", "notNull": false }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT", "notNull": false }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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, 'e0f58c96f6e849f8c8d09b75f9d4f3fe')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/23.json ================================================ { "formatVersion": 1, "database": { "version": 23, "identityHash": "163997ad95cd0d0fe167198a705f7012", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, 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" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" ] } }, { "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } } ], "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, '163997ad95cd0d0fe167198a705f7012')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/24.json ================================================ { "formatVersion": 1, "database": { "version": 24, "identityHash": "163997ad95cd0d0fe167198a705f7012", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, 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" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" ] } }, { "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } } ], "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, '163997ad95cd0d0fe167198a705f7012')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/25.json ================================================ { "formatVersion": 1, "database": { "version": 25, "identityHash": "163997ad95cd0d0fe167198a705f7012", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, 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" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" ] } }, { "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } } ], "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, '163997ad95cd0d0fe167198a705f7012')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/26.json ================================================ { "formatVersion": 1, "database": { "version": 26, "identityHash": "77118ea292614a4db192780d42629896", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `bookmarkedAt` INTEGER, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" ] } }, { "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } } ], "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, '77118ea292614a4db192780d42629896')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/27.json ================================================ { "formatVersion": 1, "database": { "version": 27, "identityHash": "1d93d14854c13d1e6158b68dcc956fd3", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `bookmarkedAt` INTEGER, `remoteSongCount` INTEGER, `playEndpointParams` TEXT, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" ] } }, { "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } } ], "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, '1d93d14854c13d1e6158b68dcc956fd3')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/28.json ================================================ { "formatVersion": 1, "database": { "version": 28, "identityHash": "331218677f74a364b5cad847a411999c", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" ] } }, { "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } } ], "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, '331218677f74a364b5cad847a411999c')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/29.json ================================================ { "formatVersion": 1, "database": { "version": 29, "identityHash": "715638298a0d1c2fa6063b1ebbccb1be", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, `isAutoSync` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isAutoSync", "columnName": "isAutoSync", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" ] } }, { "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } } ], "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, '715638298a0d1c2fa6063b1ebbccb1be')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/3.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/30.json ================================================ { "formatVersion": 1, "database": { "version": 30, "identityHash": "8f2089a8689ee426c5e3a5ea6c935041", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, `isAutoSync` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isAutoSync", "columnName": "isAutoSync", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "lyrics", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `provider` TEXT NOT NULL DEFAULT 'Unknown', PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": true }, { "fieldPath": "provider", "columnName": "provider", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } } ], "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, '8f2089a8689ee426c5e3a5ea6c935041')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/31.json ================================================ { "formatVersion": 1, "database": { "version": 31, "identityHash": "e05443bce6fbfd39a4be703c2d6467da", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, `isAutoSync` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isAutoSync", "columnName": "isAutoSync", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "lyrics", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `provider` TEXT NOT NULL DEFAULT 'Unknown', PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": true }, { "fieldPath": "provider", "columnName": "provider", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } }, { "tableName": "recognition_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackId` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `coverArtUrl` TEXT, `coverArtHqUrl` TEXT, `genre` TEXT, `releaseDate` TEXT, `label` TEXT, `shazamUrl` TEXT, `appleMusicUrl` TEXT, `spotifyUrl` TEXT, `isrc` TEXT, `youtubeVideoId` TEXT, `recognizedAt` INTEGER NOT NULL, `liked` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "trackId", "columnName": "trackId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artist", "columnName": "artist", "affinity": "TEXT", "notNull": true }, { "fieldPath": "album", "columnName": "album", "affinity": "TEXT" }, { "fieldPath": "coverArtUrl", "columnName": "coverArtUrl", "affinity": "TEXT" }, { "fieldPath": "coverArtHqUrl", "columnName": "coverArtHqUrl", "affinity": "TEXT" }, { "fieldPath": "genre", "columnName": "genre", "affinity": "TEXT" }, { "fieldPath": "releaseDate", "columnName": "releaseDate", "affinity": "TEXT" }, { "fieldPath": "label", "columnName": "label", "affinity": "TEXT" }, { "fieldPath": "shazamUrl", "columnName": "shazamUrl", "affinity": "TEXT" }, { "fieldPath": "appleMusicUrl", "columnName": "appleMusicUrl", "affinity": "TEXT" }, { "fieldPath": "spotifyUrl", "columnName": "spotifyUrl", "affinity": "TEXT" }, { "fieldPath": "isrc", "columnName": "isrc", "affinity": "TEXT" }, { "fieldPath": "youtubeVideoId", "columnName": "youtubeVideoId", "affinity": "TEXT" }, { "fieldPath": "recognizedAt", "columnName": "recognizedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_recognition_history_trackId", "unique": false, "columnNames": [ "trackId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_recognition_history_trackId` ON `${TABLE_NAME}` (`trackId`)" } ] } ], "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, 'e05443bce6fbfd39a4be703c2d6467da')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/32.json ================================================ { "formatVersion": 1, "database": { "version": 32, "identityHash": "6c3169c6fab939b089c79314ac12d9b9", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, `isAutoSync` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isAutoSync", "columnName": "isAutoSync", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "lyrics", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `provider` TEXT NOT NULL DEFAULT 'Unknown', `translatedLyrics` TEXT NOT NULL DEFAULT '', `translationLanguage` TEXT NOT NULL DEFAULT '', `translationMode` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": true }, { "fieldPath": "provider", "columnName": "provider", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" }, { "fieldPath": "translatedLyrics", "columnName": "translatedLyrics", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationLanguage", "columnName": "translationLanguage", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationMode", "columnName": "translationMode", "affinity": "TEXT", "notNull": true, "defaultValue": "''" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } }, { "tableName": "recognition_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackId` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `coverArtUrl` TEXT, `coverArtHqUrl` TEXT, `genre` TEXT, `releaseDate` TEXT, `label` TEXT, `shazamUrl` TEXT, `appleMusicUrl` TEXT, `spotifyUrl` TEXT, `isrc` TEXT, `youtubeVideoId` TEXT, `recognizedAt` INTEGER NOT NULL, `liked` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "trackId", "columnName": "trackId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artist", "columnName": "artist", "affinity": "TEXT", "notNull": true }, { "fieldPath": "album", "columnName": "album", "affinity": "TEXT" }, { "fieldPath": "coverArtUrl", "columnName": "coverArtUrl", "affinity": "TEXT" }, { "fieldPath": "coverArtHqUrl", "columnName": "coverArtHqUrl", "affinity": "TEXT" }, { "fieldPath": "genre", "columnName": "genre", "affinity": "TEXT" }, { "fieldPath": "releaseDate", "columnName": "releaseDate", "affinity": "TEXT" }, { "fieldPath": "label", "columnName": "label", "affinity": "TEXT" }, { "fieldPath": "shazamUrl", "columnName": "shazamUrl", "affinity": "TEXT" }, { "fieldPath": "appleMusicUrl", "columnName": "appleMusicUrl", "affinity": "TEXT" }, { "fieldPath": "spotifyUrl", "columnName": "spotifyUrl", "affinity": "TEXT" }, { "fieldPath": "isrc", "columnName": "isrc", "affinity": "TEXT" }, { "fieldPath": "youtubeVideoId", "columnName": "youtubeVideoId", "affinity": "TEXT" }, { "fieldPath": "recognizedAt", "columnName": "recognizedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_recognition_history_trackId", "unique": false, "columnNames": [ "trackId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_recognition_history_trackId` ON `${TABLE_NAME}` (`trackId`)" } ] } ], "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, '6c3169c6fab939b089c79314ac12d9b9')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/33.json ================================================ { "formatVersion": 1, "database": { "version": 33, "identityHash": "bd72668b1e47bd29fc4195bfc7b85064", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, `isAutoSync` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isAutoSync", "columnName": "isAutoSync", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "lyrics", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `provider` TEXT NOT NULL DEFAULT 'Unknown', `translatedLyrics` TEXT NOT NULL DEFAULT '', `translationLanguage` TEXT NOT NULL DEFAULT '', `translationMode` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": true }, { "fieldPath": "provider", "columnName": "provider", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" }, { "fieldPath": "translatedLyrics", "columnName": "translatedLyrics", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationLanguage", "columnName": "translationLanguage", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationMode", "columnName": "translationMode", "affinity": "TEXT", "notNull": true, "defaultValue": "''" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } }, { "tableName": "recognition_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackId` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `coverArtUrl` TEXT, `coverArtHqUrl` TEXT, `genre` TEXT, `releaseDate` TEXT, `label` TEXT, `shazamUrl` TEXT, `appleMusicUrl` TEXT, `spotifyUrl` TEXT, `isrc` TEXT, `youtubeVideoId` TEXT, `recognizedAt` INTEGER NOT NULL, `liked` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "trackId", "columnName": "trackId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artist", "columnName": "artist", "affinity": "TEXT", "notNull": true }, { "fieldPath": "album", "columnName": "album", "affinity": "TEXT" }, { "fieldPath": "coverArtUrl", "columnName": "coverArtUrl", "affinity": "TEXT" }, { "fieldPath": "coverArtHqUrl", "columnName": "coverArtHqUrl", "affinity": "TEXT" }, { "fieldPath": "genre", "columnName": "genre", "affinity": "TEXT" }, { "fieldPath": "releaseDate", "columnName": "releaseDate", "affinity": "TEXT" }, { "fieldPath": "label", "columnName": "label", "affinity": "TEXT" }, { "fieldPath": "shazamUrl", "columnName": "shazamUrl", "affinity": "TEXT" }, { "fieldPath": "appleMusicUrl", "columnName": "appleMusicUrl", "affinity": "TEXT" }, { "fieldPath": "spotifyUrl", "columnName": "spotifyUrl", "affinity": "TEXT" }, { "fieldPath": "isrc", "columnName": "isrc", "affinity": "TEXT" }, { "fieldPath": "youtubeVideoId", "columnName": "youtubeVideoId", "affinity": "TEXT" }, { "fieldPath": "recognizedAt", "columnName": "recognizedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_recognition_history_trackId", "unique": false, "columnNames": [ "trackId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_recognition_history_trackId` ON `${TABLE_NAME}` (`trackId`)" } ] }, { "tableName": "speed_dial_item", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `secondaryId` TEXT, `title` TEXT NOT NULL, `subtitle` TEXT, `thumbnailUrl` TEXT, `type` TEXT NOT NULL, `explicit` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "secondaryId", "columnName": "secondaryId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "subtitle", "columnName": "subtitle", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "createDate", "columnName": "createDate", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "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, 'bd72668b1e47bd29fc4195bfc7b85064')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/34.json ================================================ { "formatVersion": 1, "database": { "version": 34, "identityHash": "8e486373672922fea7afc4aa634c077b", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, `isEpisode` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isEpisode", "columnName": "isEpisode", "affinity": "INTEGER", "notNull": true, "defaultValue": "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, `isAutoSync` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isAutoSync", "columnName": "isAutoSync", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "lyrics", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `provider` TEXT NOT NULL DEFAULT 'Unknown', `translatedLyrics` TEXT NOT NULL DEFAULT '', `translationLanguage` TEXT NOT NULL DEFAULT '', `translationMode` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": true }, { "fieldPath": "provider", "columnName": "provider", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" }, { "fieldPath": "translatedLyrics", "columnName": "translatedLyrics", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationLanguage", "columnName": "translationLanguage", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationMode", "columnName": "translationMode", "affinity": "TEXT", "notNull": true, "defaultValue": "''" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } }, { "tableName": "recognition_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackId` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `coverArtUrl` TEXT, `coverArtHqUrl` TEXT, `genre` TEXT, `releaseDate` TEXT, `label` TEXT, `shazamUrl` TEXT, `appleMusicUrl` TEXT, `spotifyUrl` TEXT, `isrc` TEXT, `youtubeVideoId` TEXT, `recognizedAt` INTEGER NOT NULL, `liked` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "trackId", "columnName": "trackId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artist", "columnName": "artist", "affinity": "TEXT", "notNull": true }, { "fieldPath": "album", "columnName": "album", "affinity": "TEXT" }, { "fieldPath": "coverArtUrl", "columnName": "coverArtUrl", "affinity": "TEXT" }, { "fieldPath": "coverArtHqUrl", "columnName": "coverArtHqUrl", "affinity": "TEXT" }, { "fieldPath": "genre", "columnName": "genre", "affinity": "TEXT" }, { "fieldPath": "releaseDate", "columnName": "releaseDate", "affinity": "TEXT" }, { "fieldPath": "label", "columnName": "label", "affinity": "TEXT" }, { "fieldPath": "shazamUrl", "columnName": "shazamUrl", "affinity": "TEXT" }, { "fieldPath": "appleMusicUrl", "columnName": "appleMusicUrl", "affinity": "TEXT" }, { "fieldPath": "spotifyUrl", "columnName": "spotifyUrl", "affinity": "TEXT" }, { "fieldPath": "isrc", "columnName": "isrc", "affinity": "TEXT" }, { "fieldPath": "youtubeVideoId", "columnName": "youtubeVideoId", "affinity": "TEXT" }, { "fieldPath": "recognizedAt", "columnName": "recognizedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_recognition_history_trackId", "unique": false, "columnNames": [ "trackId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_recognition_history_trackId` ON `${TABLE_NAME}` (`trackId`)" } ] }, { "tableName": "speed_dial_item", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `secondaryId` TEXT, `title` TEXT NOT NULL, `subtitle` TEXT, `thumbnailUrl` TEXT, `type` TEXT NOT NULL, `explicit` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "secondaryId", "columnName": "secondaryId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "subtitle", "columnName": "subtitle", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "createDate", "columnName": "createDate", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "podcast", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `thumbnailUrl` TEXT, `channelId` TEXT, `bookmarkedAt` INTEGER, `lastUpdateTime` INTEGER NOT NULL, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "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, '8e486373672922fea7afc4aa634c077b')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/35.json ================================================ { "formatVersion": 1, "database": { "version": 35, "identityHash": "73924a5ef1b9fb713b5e197988a0c633", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, `isEpisode` INTEGER NOT NULL DEFAULT false, `playbackPosition` INTEGER DEFAULT NULL, `uploadEntityId` TEXT DEFAULT 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" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isEpisode", "columnName": "isEpisode", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "playbackPosition", "columnName": "playbackPosition", "affinity": "INTEGER", "defaultValue": "NULL" }, { "fieldPath": "uploadEntityId", "columnName": "uploadEntityId", "affinity": "TEXT", "defaultValue": "NULL" } ], "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, `isPodcastChannel` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isPodcastChannel", "columnName": "isPodcastChannel", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, `isAutoSync` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isAutoSync", "columnName": "isAutoSync", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "lyrics", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `provider` TEXT NOT NULL DEFAULT 'Unknown', `translatedLyrics` TEXT NOT NULL DEFAULT '', `translationLanguage` TEXT NOT NULL DEFAULT '', `translationMode` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": true }, { "fieldPath": "provider", "columnName": "provider", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" }, { "fieldPath": "translatedLyrics", "columnName": "translatedLyrics", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationLanguage", "columnName": "translationLanguage", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationMode", "columnName": "translationMode", "affinity": "TEXT", "notNull": true, "defaultValue": "''" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } }, { "tableName": "recognition_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackId` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `coverArtUrl` TEXT, `coverArtHqUrl` TEXT, `genre` TEXT, `releaseDate` TEXT, `label` TEXT, `shazamUrl` TEXT, `appleMusicUrl` TEXT, `spotifyUrl` TEXT, `isrc` TEXT, `youtubeVideoId` TEXT, `recognizedAt` INTEGER NOT NULL, `liked` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "trackId", "columnName": "trackId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artist", "columnName": "artist", "affinity": "TEXT", "notNull": true }, { "fieldPath": "album", "columnName": "album", "affinity": "TEXT" }, { "fieldPath": "coverArtUrl", "columnName": "coverArtUrl", "affinity": "TEXT" }, { "fieldPath": "coverArtHqUrl", "columnName": "coverArtHqUrl", "affinity": "TEXT" }, { "fieldPath": "genre", "columnName": "genre", "affinity": "TEXT" }, { "fieldPath": "releaseDate", "columnName": "releaseDate", "affinity": "TEXT" }, { "fieldPath": "label", "columnName": "label", "affinity": "TEXT" }, { "fieldPath": "shazamUrl", "columnName": "shazamUrl", "affinity": "TEXT" }, { "fieldPath": "appleMusicUrl", "columnName": "appleMusicUrl", "affinity": "TEXT" }, { "fieldPath": "spotifyUrl", "columnName": "spotifyUrl", "affinity": "TEXT" }, { "fieldPath": "isrc", "columnName": "isrc", "affinity": "TEXT" }, { "fieldPath": "youtubeVideoId", "columnName": "youtubeVideoId", "affinity": "TEXT" }, { "fieldPath": "recognizedAt", "columnName": "recognizedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_recognition_history_trackId", "unique": false, "columnNames": [ "trackId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_recognition_history_trackId` ON `${TABLE_NAME}` (`trackId`)" } ] }, { "tableName": "speed_dial_item", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `secondaryId` TEXT, `title` TEXT NOT NULL, `subtitle` TEXT, `thumbnailUrl` TEXT, `type` TEXT NOT NULL, `explicit` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "secondaryId", "columnName": "secondaryId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "subtitle", "columnName": "subtitle", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "createDate", "columnName": "createDate", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "podcast", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `thumbnailUrl` TEXT, `channelId` TEXT, `bookmarkedAt` INTEGER, `lastUpdateTime` INTEGER NOT NULL, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "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, '73924a5ef1b9fb713b5e197988a0c633')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/36.json ================================================ { "formatVersion": 1, "database": { "version": 36, "identityHash": "afcd734f45bc50034a6692f5255e7b92", "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, `explicit` INTEGER NOT NULL DEFAULT 0, `year` INTEGER, `date` INTEGER, `dateModified` INTEGER, `liked` INTEGER NOT NULL, `likedDate` INTEGER, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, `dateDownload` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, `lyricsOffset` INTEGER NOT NULL DEFAULT 0, `romanizeLyrics` INTEGER NOT NULL DEFAULT true, `isDownloaded` INTEGER NOT NULL DEFAULT 0, `isUploaded` INTEGER NOT NULL DEFAULT false, `isVideo` INTEGER NOT NULL DEFAULT false, `isEpisode` INTEGER NOT NULL DEFAULT false, `playbackPosition` INTEGER DEFAULT NULL, `uploadEntityId` TEXT DEFAULT NULL, `isCached` INTEGER NOT NULL DEFAULT 0, 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" }, { "fieldPath": "albumId", "columnName": "albumId", "affinity": "TEXT" }, { "fieldPath": "albumName", "columnName": "albumName", "affinity": "TEXT" }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "date", "columnName": "date", "affinity": "INTEGER" }, { "fieldPath": "dateModified", "columnName": "dateModified", "affinity": "INTEGER" }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "totalPlayTime", "columnName": "totalPlayTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "dateDownload", "columnName": "dateDownload", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" }, { "fieldPath": "lyricsOffset", "columnName": "lyricsOffset", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "romanizeLyrics", "columnName": "romanizeLyrics", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "isDownloaded", "columnName": "isDownloaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isVideo", "columnName": "isVideo", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isEpisode", "columnName": "isEpisode", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "playbackPosition", "columnName": "playbackPosition", "affinity": "INTEGER", "defaultValue": "NULL" }, { "fieldPath": "uploadEntityId", "columnName": "uploadEntityId", "affinity": "TEXT", "defaultValue": "NULL" }, { "fieldPath": "isCached", "columnName": "isCached", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" } ], "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`)" } ] }, { "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, `isLocal` INTEGER NOT NULL DEFAULT false, `isPodcastChannel` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isPodcastChannel", "columnName": "isPodcastChannel", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `explicit` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, `likedDate` INTEGER, `inLibrary` INTEGER, `isLocal` INTEGER NOT NULL DEFAULT false, `isUploaded` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "playlistId", "columnName": "playlistId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "year", "columnName": "year", "affinity": "INTEGER" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "themeColor", "columnName": "themeColor", "affinity": "INTEGER" }, { "fieldPath": "songCount", "columnName": "songCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true, "defaultValue": "0" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "likedDate", "columnName": "likedDate", "affinity": "INTEGER" }, { "fieldPath": "inLibrary", "columnName": "inLibrary", "affinity": "INTEGER" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isUploaded", "columnName": "isUploaded", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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, `thumbnailUrl` TEXT, `shuffleEndpointParams` TEXT, `radioEndpointParams` TEXT, `isLocal` INTEGER NOT NULL DEFAULT false, `isAutoSync` INTEGER NOT NULL DEFAULT false, 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" }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER" }, { "fieldPath": "isEditable", "columnName": "isEditable", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "remoteSongCount", "columnName": "remoteSongCount", "affinity": "INTEGER" }, { "fieldPath": "playEndpointParams", "columnName": "playEndpointParams", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "shuffleEndpointParams", "columnName": "shuffleEndpointParams", "affinity": "TEXT" }, { "fieldPath": "radioEndpointParams", "columnName": "radioEndpointParams", "affinity": "TEXT" }, { "fieldPath": "isLocal", "columnName": "isLocal", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" }, { "fieldPath": "isAutoSync", "columnName": "isAutoSync", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "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" } ], "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`)" } ] }, { "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, `perceptualLoudnessDb` 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" }, { "fieldPath": "contentLength", "columnName": "contentLength", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "loudnessDb", "columnName": "loudnessDb", "affinity": "REAL" }, { "fieldPath": "perceptualLoudnessDb", "columnName": "perceptualLoudnessDb", "affinity": "REAL" }, { "fieldPath": "playbackUrl", "columnName": "playbackUrl", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "lyrics", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, `provider` TEXT NOT NULL DEFAULT 'Unknown', `translatedLyrics` TEXT NOT NULL DEFAULT '', `translationLanguage` TEXT NOT NULL DEFAULT '', `translationMode` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lyrics", "columnName": "lyrics", "affinity": "TEXT", "notNull": true }, { "fieldPath": "provider", "columnName": "provider", "affinity": "TEXT", "notNull": true, "defaultValue": "'Unknown'" }, { "fieldPath": "translatedLyrics", "columnName": "translatedLyrics", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationLanguage", "columnName": "translationLanguage", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "translationMode", "columnName": "translationMode", "affinity": "TEXT", "notNull": true, "defaultValue": "''" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "event", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "songId", "columnName": "songId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timestamp", "columnName": "timestamp", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "playTime", "columnName": "playTime", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_event_songId", "unique": false, "columnNames": [ "songId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" } ], "foreignKeys": [ { "table": "song", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ "songId" ], "referencedColumns": [ "id" ] } ] }, { "tableName": "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" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "videoId" ] } }, { "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" ] } }, { "tableName": "recognition_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trackId` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT, `coverArtUrl` TEXT, `coverArtHqUrl` TEXT, `genre` TEXT, `releaseDate` TEXT, `label` TEXT, `shazamUrl` TEXT, `appleMusicUrl` TEXT, `spotifyUrl` TEXT, `isrc` TEXT, `youtubeVideoId` TEXT, `recognizedAt` INTEGER NOT NULL, `liked` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "trackId", "columnName": "trackId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "artist", "columnName": "artist", "affinity": "TEXT", "notNull": true }, { "fieldPath": "album", "columnName": "album", "affinity": "TEXT" }, { "fieldPath": "coverArtUrl", "columnName": "coverArtUrl", "affinity": "TEXT" }, { "fieldPath": "coverArtHqUrl", "columnName": "coverArtHqUrl", "affinity": "TEXT" }, { "fieldPath": "genre", "columnName": "genre", "affinity": "TEXT" }, { "fieldPath": "releaseDate", "columnName": "releaseDate", "affinity": "TEXT" }, { "fieldPath": "label", "columnName": "label", "affinity": "TEXT" }, { "fieldPath": "shazamUrl", "columnName": "shazamUrl", "affinity": "TEXT" }, { "fieldPath": "appleMusicUrl", "columnName": "appleMusicUrl", "affinity": "TEXT" }, { "fieldPath": "spotifyUrl", "columnName": "spotifyUrl", "affinity": "TEXT" }, { "fieldPath": "isrc", "columnName": "isrc", "affinity": "TEXT" }, { "fieldPath": "youtubeVideoId", "columnName": "youtubeVideoId", "affinity": "TEXT" }, { "fieldPath": "recognizedAt", "columnName": "recognizedAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "liked", "columnName": "liked", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_recognition_history_trackId", "unique": false, "columnNames": [ "trackId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_recognition_history_trackId` ON `${TABLE_NAME}` (`trackId`)" } ] }, { "tableName": "speed_dial_item", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `secondaryId` TEXT, `title` TEXT NOT NULL, `subtitle` TEXT, `thumbnailUrl` TEXT, `type` TEXT NOT NULL, `explicit` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "secondaryId", "columnName": "secondaryId", "affinity": "TEXT" }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "subtitle", "columnName": "subtitle", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "explicit", "columnName": "explicit", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "createDate", "columnName": "createDate", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] } }, { "tableName": "podcast", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `thumbnailUrl` TEXT, `channelId` TEXT, `bookmarkedAt` INTEGER, `lastUpdateTime` INTEGER NOT NULL, `libraryAddToken` TEXT, `libraryRemoveToken` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "author", "columnName": "author", "affinity": "TEXT" }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnailUrl", "affinity": "TEXT" }, { "fieldPath": "channelId", "columnName": "channelId", "affinity": "TEXT" }, { "fieldPath": "bookmarkedAt", "columnName": "bookmarkedAt", "affinity": "INTEGER" }, { "fieldPath": "lastUpdateTime", "columnName": "lastUpdateTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "libraryAddToken", "columnName": "libraryAddToken", "affinity": "TEXT" }, { "fieldPath": "libraryRemoveToken", "columnName": "libraryRemoveToken", "affinity": "TEXT" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "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, 'afcd734f45bc50034a6692f5255e7b92')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/4.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/5.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/6.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/7.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/8.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.metrolist.music.db.InternalDatabase/9.json ================================================ { "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')" ] } } ================================================ FILE: app/src/debug/res/xml/shortcuts.xml ================================================ ================================================ FILE: app/src/foss/AndroidManifest.xml ================================================ ================================================ FILE: app/src/foss/kotlin/com/metrolist/music/cast/CastOptionsProvider.kt ================================================ package com.metrolist.music.cast /** * Stub CastOptionsProvider for F-Droid builds. * The AndroidManifest reference is removed via manifest merger. */ class CastOptionsProvider ================================================ FILE: app/src/foss/kotlin/com/metrolist/music/playback/CastConnectionHandler.kt ================================================ package com.metrolist.music.playback import android.content.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** * Stub CastConnectionHandler for F-Droid builds. * Cast functionality is not available without Google Play Services. */ class CastConnectionHandler( context: Context, scope: CoroutineScope, musicService: MusicService ) { private val _isCasting = MutableStateFlow(false) val isCasting: StateFlow = _isCasting private val _isConnecting = MutableStateFlow(false) val isConnecting: StateFlow = _isConnecting private val _castDeviceName = MutableStateFlow(null) val castDeviceName: StateFlow = _castDeviceName private val _castPosition = MutableStateFlow(0L) val castPosition: StateFlow = _castPosition private val _castDuration = MutableStateFlow(0L) val castDuration: StateFlow = _castDuration private val _castIsPlaying = MutableStateFlow(false) val castIsPlaying: StateFlow = _castIsPlaying private val _castIsBuffering = MutableStateFlow(false) val castIsBuffering: StateFlow = _castIsBuffering private val _castVolume = MutableStateFlow(1.0f) val castVolume: StateFlow = _castVolume var isSyncingFromCast: Boolean = false private set fun initialize(): Boolean = false fun disconnect() {} fun loadCurrentMedia() {} fun loadMedia(metadata: com.metrolist.music.models.MediaMetadata) {} fun play() {} fun pause() {} fun seekTo(position: Long) {} fun setVolume(volume: Float) {} fun skipToNext() {} fun skipToPrevious() {} fun navigateToMediaIfInQueue(mediaId: String): Boolean = false fun release() {} } ================================================ FILE: app/src/foss/kotlin/com/metrolist/music/ui/component/CastButton.kt ================================================ package com.metrolist.music.ui.component import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color /** * Stub CastButton for F-Droid builds. * Does not render anything - Cast not available without GMS. */ @Composable fun CastButton( modifier: Modifier = Modifier, tintColor: Color = MaterialTheme.colorScheme.onSurface, ) { // No-op: Cast not available in FOSS build } ================================================ FILE: app/src/gms/kotlin/com/metrolist/music/cast/CastManager.kt ================================================ package com.metrolist.music.cast import android.content.Context import androidx.media3.cast.CastPlayer import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.common.MediaItem import androidx.media3.common.Player import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.gms.cast.framework.CastStateListener import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import timber.log.Timber /** * Manages Google Cast integration for the music player. * Handles switching between local ExoPlayer and remote CastPlayer. */ class CastManager( private val context: Context ) : SessionAvailabilityListener, CastStateListener { private var castContext: CastContext? = null private var castPlayer: CastPlayer? = null private val _isCasting = MutableStateFlow(false) val isCasting: StateFlow = _isCasting.asStateFlow() private val _castState = MutableStateFlow(CastState.NO_DEVICES_AVAILABLE) val castState: StateFlow = _castState.asStateFlow() private var onCastSessionStarted: ((CastPlayer) -> Unit)? = null private var onCastSessionEnded: (() -> Unit)? = null /** * Initialize the Cast context. Should be called when the activity is created. * This is safe to call even if Google Play Services is not available. */ @Suppress("DEPRECATION") fun initialize() { try { castContext = CastContext.getSharedInstance(context) castContext?.addCastStateListener(this) // Using deprecated constructor and setSessionAvailabilityListener as the new // CastPlayer.Builder API requires a local player which we don't use in this architecture castPlayer = CastPlayer(castContext!!) castPlayer?.setSessionAvailabilityListener(this) _castState.value = castContext?.castState ?: CastState.NO_DEVICES_AVAILABLE Timber.d("CastManager initialized successfully") } catch (e: Exception) { Timber.e(e, "Failed to initialize CastManager - Cast may not be available on this device") castContext = null castPlayer = null } } /** * Set callbacks for cast session events. */ fun setSessionCallbacks( onStarted: (CastPlayer) -> Unit, onEnded: () -> Unit ) { onCastSessionStarted = onStarted onCastSessionEnded = onEnded } /** * Get the CastPlayer instance if available. */ fun getCastPlayer(): CastPlayer? = castPlayer /** * Check if casting is currently active. */ @Suppress("DEPRECATION") fun isCastSessionAvailable(): Boolean = castPlayer?.isCastSessionAvailable == true /** * Get the current playback position from the cast player. */ fun getCurrentPosition(): Long = castPlayer?.currentPosition ?: 0 /** * Get whether the cast player is currently playing. */ fun isPlaying(): Boolean = castPlayer?.isPlaying == true /** * Load media items into the cast player. */ fun loadMediaItems( mediaItems: List, startIndex: Int = 0, startPositionMs: Long = 0 ) { castPlayer?.let { player -> player.setMediaItems(mediaItems, startIndex, startPositionMs) player.prepare() player.play() } } /** * Add a listener to the cast player. */ fun addListener(listener: Player.Listener) { castPlayer?.addListener(listener) } /** * Remove a listener from the cast player. */ fun removeListener(listener: Player.Listener) { castPlayer?.removeListener(listener) } override fun onCastStateChanged(state: Int) { _castState.value = state Timber.d("Cast state changed: $state") } override fun onCastSessionAvailable() { _isCasting.value = true castPlayer?.let { player -> onCastSessionStarted?.invoke(player) } Timber.d("Cast session available") } override fun onCastSessionUnavailable() { _isCasting.value = false onCastSessionEnded?.invoke() Timber.d("Cast session unavailable") } /** * Release resources. Should be called when the service is destroyed. */ @Suppress("DEPRECATION") fun release() { castContext?.removeCastStateListener(this) castPlayer?.setSessionAvailabilityListener(null) castPlayer?.release() castPlayer = null castContext = null } companion object { /** * Check if Cast is available on this device. */ fun isCastAvailable(context: Context): Boolean { return try { CastContext.getSharedInstance(context) true } catch (e: Exception) { Timber.d("Cast not available: ${e.message}") false } } } } ================================================ FILE: app/src/gms/kotlin/com/metrolist/music/cast/CastOptionsProvider.kt ================================================ package com.metrolist.music.cast import android.content.Context import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.framework.CastOptions import com.google.android.gms.cast.framework.OptionsProvider import com.google.android.gms.cast.framework.SessionProvider import com.google.android.gms.cast.framework.media.CastMediaOptions import com.google.android.gms.cast.framework.media.MediaIntentReceiver import com.google.android.gms.cast.framework.media.NotificationOptions /** * CastOptionsProvider for Google Cast integration. * This class provides the Cast options for the app. */ class CastOptionsProvider : OptionsProvider { override fun getCastOptions(context: Context): CastOptions { val notificationOptions = NotificationOptions.Builder() .setActions( listOf( MediaIntentReceiver.ACTION_SKIP_PREV, MediaIntentReceiver.ACTION_TOGGLE_PLAYBACK, MediaIntentReceiver.ACTION_SKIP_NEXT, MediaIntentReceiver.ACTION_STOP_CASTING ), intArrayOf(1, 2) // Indices of actions for compact view ) .build() val mediaOptions = CastMediaOptions.Builder() .setNotificationOptions(notificationOptions) .build() return CastOptions.Builder() .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) .setCastMediaOptions(mediaOptions) .setStopReceiverApplicationWhenEndingSession(true) .build() } override fun getAdditionalSessionProviders(context: Context): List? { return null } } ================================================ FILE: app/src/gms/kotlin/com/metrolist/music/playback/CastConnectionHandler.kt ================================================ package com.metrolist.music.playback import android.content.Context import android.net.Uri import androidx.media3.common.Player import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaLoadRequestData import com.google.android.gms.cast.MediaMetadata import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaStatus import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.SessionManager import com.google.android.gms.cast.framework.SessionManagerListener import com.google.android.gms.cast.framework.media.RemoteMediaClient import com.google.android.gms.common.images.WebImage import com.metrolist.music.extensions.metadata import com.metrolist.music.models.MediaMetadata as AppMediaMetadata import com.metrolist.music.ui.utils.resize import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber /** * Manages Google Cast connections and media playback on Cast devices. * This class handles the entire Cast lifecycle including: * - Device discovery * - Session management * - Media loading and playback control * - Synchronization between local and remote playback */ class CastConnectionHandler( private val context: Context, private val scope: CoroutineScope, private val musicService: MusicService ) { private var castContext: CastContext? = null private var sessionManager: SessionManager? = null private var mediaRouter: MediaRouter? = null private var routeSelector: MediaRouteSelector? = null private var remoteMediaClient: RemoteMediaClient? = null private var castSession: CastSession? = null private val _isCasting = MutableStateFlow(false) val isCasting: StateFlow = _isCasting.asStateFlow() private val _isConnecting = MutableStateFlow(false) val isConnecting: StateFlow = _isConnecting.asStateFlow() private val _castDeviceName = MutableStateFlow(null) val castDeviceName: StateFlow = _castDeviceName.asStateFlow() private val _castPosition = MutableStateFlow(0L) val castPosition: StateFlow = _castPosition.asStateFlow() private val _castDuration = MutableStateFlow(0L) val castDuration: StateFlow = _castDuration.asStateFlow() private val _castIsPlaying = MutableStateFlow(false) val castIsPlaying: StateFlow = _castIsPlaying.asStateFlow() private val _castIsBuffering = MutableStateFlow(false) val castIsBuffering: StateFlow = _castIsBuffering.asStateFlow() private val _castVolume = MutableStateFlow(1.0f) val castVolume: StateFlow = _castVolume.asStateFlow() private var positionUpdateJob: Job? = null private var currentMediaId: String? = null private var lastCastItemId: Int = -1 private var isReloadingQueue: Boolean = false // Flag to prevent reverse sync when Cast triggers local player update var isSyncingFromCast: Boolean = false private set private val remoteMediaClientCallback = object : RemoteMediaClient.Callback() { override fun onStatusUpdated() { remoteMediaClient?.let { client -> val mediaStatus = client.mediaStatus val playerState = mediaStatus?.playerState // Show as "playing" when playing OR buffering/loading (so pause icon shows during buffering) _castIsPlaying.value = playerState == MediaStatus.PLAYER_STATE_PLAYING || playerState == MediaStatus.PLAYER_STATE_BUFFERING || playerState == MediaStatus.PLAYER_STATE_LOADING _castIsBuffering.value = playerState == MediaStatus.PLAYER_STATE_BUFFERING || playerState == MediaStatus.PLAYER_STATE_LOADING _castDuration.value = client.streamDuration // Check if the current Cast item changed (user skipped on Cast widget) val currentItemId = mediaStatus?.currentItemId ?: -1 if (currentItemId != -1 && currentItemId != lastCastItemId && lastCastItemId != -1 && !isReloadingQueue && mediaStatus != null) { Timber.d("Cast item changed: $lastCastItemId -> $currentItemId") handleCastItemChanged(mediaStatus) } lastCastItemId = currentItemId Timber.d("Cast status updated: playing=${_castIsPlaying.value}, buffering=${_castIsBuffering.value}, itemId=$currentItemId") } } override fun onMediaError(error: com.google.android.gms.cast.MediaError) { Timber.e("Cast media error: ${error.reason}") } override fun onQueueStatusUpdated() { Timber.d("Cast queue status updated") } } // Job for resetting sync flag private var syncResetJob: Job? = null /** * Handle when Cast changes to a different item (user pressed next/prev on Cast widget) * This syncs the local player - we don't reload the queue since the item is already loaded */ private fun handleCastItemChanged(mediaStatus: MediaStatus) { val queueItems = mediaStatus.queueItems if (queueItems.isEmpty()) return val currentItemId = mediaStatus.currentItemId val currentIndex = queueItems.indexOfFirst { it.itemId == currentItemId } if (currentIndex < 0) return // Get the mediaId from the current Cast item's custom data val currentQueueItem = queueItems[currentIndex] val customData = currentQueueItem.media?.customData val castMediaId = customData?.optString("mediaId") Timber.d("Cast switched to item: index=$currentIndex, mediaId=$castMediaId, queueSize=${queueItems.size}") if (castMediaId != null && castMediaId != currentMediaId) { currentMediaId = castMediaId // Cancel any pending sync reset syncResetJob?.cancel() // Set flag immediately to prevent reverse sync isSyncingFromCast = true // Find this song in the local player queue and switch to it val player = musicService.player val playerItemCount = player.mediaItemCount // Find the matching item in local player for (i in 0 until playerItemCount) { val mediaItem = player.getMediaItemAt(i) if (mediaItem.mediaId == castMediaId) { Timber.d("Syncing local player to index $i (mediaId=$castMediaId)") // Ensure local player is paused before seeking player.pause() // Move local player to match Cast (just for metadata sync) player.seekTo(i, 0) // Make absolutely sure local player stays paused player.pause() // Extend queue if needed (in background) val itemsAhead = queueItems.size - 1 - currentIndex val itemsBehind = currentIndex if (itemsAhead < 2 || itemsBehind < 2) { scope.launch { val metadata = mediaItem.metadata if (metadata != null) { extendQueueIfNeeded(i, playerItemCount, queueItems) } } } break } } // Reset flag after a short delay syncResetJob = scope.launch { delay(300) isSyncingFromCast = false } } } /** * Extend the Cast queue by adding more items at the edges if needed * This avoids a full queue reload which causes the widget to refresh */ private suspend fun extendQueueIfNeeded(localPlayerIndex: Int, playerItemCount: Int, currentCastQueue: List) { if (isReloadingQueue) return val client = remoteMediaClient ?: return val currentCastIndex = currentCastQueue.indexOfFirst { it.media?.customData?.optString("mediaId") == currentMediaId } if (currentCastIndex < 0) return isReloadingQueue = true try { // Add more items to the end of queue if needed val itemsAhead = currentCastQueue.size - 1 - currentCastIndex if (itemsAhead < 2) { // Find what songs we need to add val lastCastItem = currentCastQueue.lastOrNull() val lastMediaId = lastCastItem?.media?.customData?.optString("mediaId") // Find the index of the last Cast item in local player var lastLocalIndex = -1 for (i in 0 until playerItemCount) { if (musicService.player.getMediaItemAt(i).mediaId == lastMediaId) { lastLocalIndex = i break } } // Add next items from local player if (lastLocalIndex >= 0 && lastLocalIndex < playerItemCount - 1) { val itemsToAdd = mutableListOf() val addCount = minOf(2, playerItemCount - lastLocalIndex - 1) for (i in 1..addCount) { val nextItem = musicService.player.getMediaItemAt(lastLocalIndex + i) nextItem.metadata?.let { metadata -> buildMediaInfo(metadata)?.let { mediaInfo -> itemsToAdd.add(MediaQueueItem.Builder(mediaInfo).build()) } } } if (itemsToAdd.isNotEmpty()) { Timber.d("Appending ${itemsToAdd.size} items to Cast queue") withContext(Dispatchers.Main) { client.queueAppendItem(itemsToAdd.first(), null) } } } } } catch (e: Exception) { Timber.e(e, "Failed to extend Cast queue") } finally { delay(500) isReloadingQueue = false } } /** * Reload the Cast queue centered on the current item * This updates prev/next context after a skip * Respects shuffle mode when determining prev/next items */ private fun reloadQueueForCurrentItem(metadata: AppMediaMetadata) { if (!_isCasting.value || isReloadingQueue) return isReloadingQueue = true scope.launch { try { val player = musicService.player val currentIndex = player.currentMediaItemIndex val shuffleEnabled = player.shuffleModeEnabled val timeline = player.currentTimeline // Build new queue items: up to 2 previous, current, and up to 2 next val queueItems = mutableListOf() // Get previous items respecting shuffle order val prevItems = mutableListOf() if (!timeline.isEmpty) { var prevIdx = currentIndex for (i in 0 until 2) { prevIdx = timeline.getPreviousWindowIndex(prevIdx, Player.REPEAT_MODE_OFF, shuffleEnabled) if (prevIdx == androidx.media3.common.C.INDEX_UNSET) break prevItems.add(0, player.getMediaItemAt(prevIdx)) } } // Add previous items for (prevItem in prevItems) { prevItem.metadata?.let { prevMetadata -> buildMediaInfo(prevMetadata)?.let { mediaInfo -> queueItems.add(MediaQueueItem.Builder(mediaInfo).build()) } } } val startIndex = queueItems.size // Current item index after previous items // Add current item val currentMediaInfo = buildMediaInfo(metadata) if (currentMediaInfo != null) { queueItems.add(MediaQueueItem.Builder(currentMediaInfo).build()) } // Get next items respecting shuffle order if (!timeline.isEmpty) { var nextIdx = currentIndex for (i in 0 until 2) { nextIdx = timeline.getNextWindowIndex(nextIdx, Player.REPEAT_MODE_OFF, shuffleEnabled) if (nextIdx == androidx.media3.common.C.INDEX_UNSET) break val nextItem = player.getMediaItemAt(nextIdx) nextItem.metadata?.let { nextMetadata -> buildMediaInfo(nextMetadata)?.let { mediaInfo -> queueItems.add(MediaQueueItem.Builder(mediaInfo).build()) } } } } if (queueItems.isNotEmpty()) { Timber.d("Reloading Cast queue: ${queueItems.size} items, startIndex=$startIndex, shuffle=$shuffleEnabled") withContext(Dispatchers.Main) { remoteMediaClient?.queueLoad( queueItems.toTypedArray(), startIndex, MediaStatus.REPEAT_MODE_REPEAT_OFF, 0L, // Start from beginning since Cast already has position org.json.JSONObject() ) } } } catch (e: Exception) { Timber.e(e, "Failed to reload Cast queue") } finally { // Delay before allowing another reload to prevent rapid reloads delay(1000) isReloadingQueue = false } } } private val sessionManagerListener = object : SessionManagerListener { override fun onSessionStarting(session: CastSession) { Timber.d("Cast session starting") _isConnecting.value = true } override fun onSessionStarted(session: CastSession, sessionId: String) { Timber.d("Cast session started: $sessionId") _isCasting.value = true _isConnecting.value = false _castDeviceName.value = session.castDevice?.friendlyName castSession = session remoteMediaClient = session.remoteMediaClient remoteMediaClient?.registerCallback(remoteMediaClientCallback) // Get initial volume _castVolume.value = session.volume.toFloat() // Start position updates startPositionUpdates() // Load current media loadCurrentMedia() } override fun onSessionStartFailed(session: CastSession, error: Int) { Timber.e("Cast session start failed: $error") _isCasting.value = false _isConnecting.value = false } override fun onSessionEnding(session: CastSession) { Timber.d("Cast session ending") // Capture Cast position before session ends val castPosition = remoteMediaClient?.approximateStreamPosition ?: _castPosition.value if (castPosition > 0) { // Seek local player to Cast position so playback can continue from there musicService.player.seekTo(castPosition) Timber.d("Saved Cast position: $castPosition") } } override fun onSessionEnded(session: CastSession, error: Int) { Timber.d("Cast session ended: error=$error") _isCasting.value = false _isConnecting.value = false _castDeviceName.value = null castSession = null remoteMediaClient?.unregisterCallback(remoteMediaClientCallback) remoteMediaClient = null stopPositionUpdates() // Pause local playback when disconnecting from Cast musicService.player.pause() } override fun onSessionResuming(session: CastSession, sessionId: String) { _isConnecting.value = true } override fun onSessionResumed(session: CastSession, wasSuspended: Boolean) { _isCasting.value = true _isConnecting.value = false _castDeviceName.value = session.castDevice?.friendlyName remoteMediaClient = session.remoteMediaClient remoteMediaClient?.registerCallback(remoteMediaClientCallback) startPositionUpdates() } override fun onSessionResumeFailed(session: CastSession, error: Int) { _isConnecting.value = false } override fun onSessionSuspended(session: CastSession, reason: Int) {} } fun initialize(): Boolean { return try { castContext = CastContext.getSharedInstance(context) sessionManager = castContext?.sessionManager mediaRouter = MediaRouter.getInstance(context) routeSelector = MediaRouteSelector.Builder() .addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)) .build() sessionManager?.addSessionManagerListener(sessionManagerListener, CastSession::class.java) // Check if already connected sessionManager?.currentCastSession?.let { session -> _isCasting.value = true _castDeviceName.value = session.castDevice?.friendlyName remoteMediaClient = session.remoteMediaClient remoteMediaClient?.registerCallback(remoteMediaClientCallback) startPositionUpdates() } true } catch (e: Exception) { Timber.e(e, "Failed to initialize Cast") false } } fun getAvailableRoutes(): List { val router = mediaRouter ?: return emptyList() val selector = routeSelector ?: return emptyList() return router.routes.filter { route -> route.matchesSelector(selector) && !route.isDefault } } fun connectToRoute(route: MediaRouter.RouteInfo) { // Ensure we're initialized before trying to connect if (mediaRouter == null) { initialize() } _isConnecting.value = true mediaRouter?.selectRoute(route) } fun disconnect() { sessionManager?.endCurrentSession(true) } fun loadCurrentMedia() { val metadata = musicService.currentMediaMetadata.value ?: return loadMediaWithQueue(metadata) } fun loadMedia(metadata: AppMediaMetadata) { loadMediaWithQueue(metadata) } /** * Build MediaInfo for a single track */ private suspend fun buildMediaInfo(metadata: AppMediaMetadata): MediaInfo? { val streamUrl = musicService.getStreamUrl(metadata.id) ?: return null val castMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK).apply { putString(MediaMetadata.KEY_TITLE, metadata.title) putString(MediaMetadata.KEY_ARTIST, metadata.artists.joinToString(", ") { it.name }) metadata.album?.title?.let { putString(MediaMetadata.KEY_ALBUM_TITLE, it) } metadata.thumbnailUrl?.let { thumbUrl -> // Use high quality thumbnail (1080x1080) for Cast display val highQualityUrl = thumbUrl.resize(1080, 1080) addImage(WebImage(Uri.parse(highQualityUrl))) } } return MediaInfo.Builder(streamUrl) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setContentType("audio/mp4") .setMetadata(castMetadata) .setCustomData(org.json.JSONObject().put("mediaId", metadata.id)) .build() } /** * Load media with queue context to enable skip prev/next buttons on Cast widget * Loads up to 5 items: 2 previous, current, and 2 next for smoother transitions * Respects shuffle mode when determining prev/next items */ private fun loadMediaWithQueue(metadata: AppMediaMetadata) { if (!_isCasting.value) return isReloadingQueue = true // Prevent sync logic from triggering during load scope.launch { try { currentMediaId = metadata.id _castIsBuffering.value = true lastCastItemId = -1 // Reset to prevent false change detection val player = musicService.player val currentIndex = player.currentMediaItemIndex val mediaItemCount = player.mediaItemCount val shuffleEnabled = player.shuffleModeEnabled val timeline = player.currentTimeline // Build queue items: up to 2 previous, current, and up to 2 next songs val queueItems = mutableListOf() // Get previous items respecting shuffle order val prevItems = mutableListOf() if (!timeline.isEmpty) { var prevIdx = currentIndex for (i in 0 until 2) { prevIdx = timeline.getPreviousWindowIndex(prevIdx, Player.REPEAT_MODE_OFF, shuffleEnabled) if (prevIdx == androidx.media3.common.C.INDEX_UNSET) break prevItems.add(0, player.getMediaItemAt(prevIdx)) // Add at beginning to maintain order } } // Add previous items for (prevItem in prevItems) { prevItem.metadata?.let { prevMetadata -> buildMediaInfo(prevMetadata)?.let { mediaInfo -> queueItems.add(MediaQueueItem.Builder(mediaInfo).build()) } } } val startIndex = queueItems.size // Current item index after previous items // Add current item val currentMediaInfo = buildMediaInfo(metadata) if (currentMediaInfo == null) { Timber.e("Failed to get stream URL for Cast") _castIsBuffering.value = false return@launch } queueItems.add(MediaQueueItem.Builder(currentMediaInfo).build()) // Get next items respecting shuffle order if (!timeline.isEmpty) { var nextIdx = currentIndex for (i in 0 until 2) { nextIdx = timeline.getNextWindowIndex(nextIdx, Player.REPEAT_MODE_OFF, shuffleEnabled) if (nextIdx == androidx.media3.common.C.INDEX_UNSET) break val nextItem = player.getMediaItemAt(nextIdx) nextItem.metadata?.let { nextMetadata -> buildMediaInfo(nextMetadata)?.let { mediaInfo -> queueItems.add(MediaQueueItem.Builder(mediaInfo).build()) } } } } // Get current position from local player if same song val startPosition = if (player.currentMediaItem?.mediaId == metadata.id) { player.currentPosition } else { 0L } Timber.d("Loading Cast queue: ${queueItems.size} items, startIndex=$startIndex, shuffle=$shuffleEnabled") withContext(Dispatchers.Main) { val client = remoteMediaClient ?: return@withContext // Load the queue client.queueLoad( queueItems.toTypedArray(), startIndex, MediaStatus.REPEAT_MODE_REPEAT_OFF, startPosition, org.json.JSONObject() ) // Pause local playback musicService.player.pause() } Timber.d("Loaded media on Cast: ${metadata.title}") } catch (e: Exception) { Timber.e(e, "Failed to load media on Cast") _castIsBuffering.value = false } finally { // Allow sync logic after a delay delay(1500) isReloadingQueue = false } } } fun play() { remoteMediaClient?.play() } fun pause() { remoteMediaClient?.pause() } fun seekTo(position: Long) { val seekOptions = MediaSeekOptions.Builder() .setPosition(position) .build() remoteMediaClient?.seek(seekOptions) } /** * Set the Cast device volume (0.0 to 1.0) */ fun setVolume(volume: Float) { try { val clampedVolume = volume.coerceIn(0f, 1f) castSession?.volume = clampedVolume.toDouble() _castVolume.value = clampedVolume Timber.d("Set Cast volume to $clampedVolume") } catch (e: Exception) { Timber.e(e, "Failed to set Cast volume") } } /** * Try to navigate to a media item if it's already in the Cast queue * Returns true if successful, false if the item isn't in the queue */ fun navigateToMediaIfInQueue(mediaId: String): Boolean { val client = remoteMediaClient ?: return false val mediaStatus = client.mediaStatus ?: return false val queueItems = mediaStatus.queueItems if (queueItems.isEmpty()) return false // Find the item in Cast queue val targetIndex = queueItems.indexOfFirst { it.media?.customData?.optString("mediaId") == mediaId } if (targetIndex < 0) { Timber.d("Media $mediaId not found in Cast queue") return false } val currentItemId = mediaStatus.currentItemId val currentIndex = queueItems.indexOfFirst { it.itemId == currentItemId } if (targetIndex == currentIndex) { // Already on this item - ensure local player is paused currentMediaId = mediaId musicService.player.pause() return true } // Navigate to the item on Cast val targetItem = queueItems[targetIndex] Timber.d("Navigating Cast to item at index $targetIndex (mediaId=$mediaId)") // Set flag to prevent reverse sync loop isSyncingFromCast = true // Update local player to match (for UI sync) - find the item in local queue val player = musicService.player for (i in 0 until player.mediaItemCount) { if (player.getMediaItemAt(i).mediaId == mediaId) { player.seekTo(i, 0) break } } player.pause() // Navigate Cast client.queueJumpToItem(targetItem.itemId, org.json.JSONObject()) currentMediaId = mediaId // Reset sync flag after a short delay scope.launch { delay(300) isSyncingFromCast = false } return true } fun skipToNext() { // First try to use Cast queue val client = remoteMediaClient val mediaStatus = client?.mediaStatus if (mediaStatus != null && mediaStatus.queueItemCount > 0) { // Check if there's a next item in Cast queue val currentItemId = mediaStatus.currentItemId val queueItems = mediaStatus.queueItems val currentIndex = queueItems.indexOfFirst { it.itemId == currentItemId } if (currentIndex >= 0 && currentIndex < queueItems.size - 1) { // There's a next item in Cast queue, use it client.queueNext(org.json.JSONObject()) // Ensure local player stays paused musicService.player.pause() return } } // Fall back to loading from MusicService queue val player = musicService.player if (player.hasNextMediaItem()) { // Pause first, then seek player.pause() player.seekToNextMediaItem() // The player listener will handle loading the new media to Cast } } fun skipToPrevious() { // First try to use Cast queue val client = remoteMediaClient val mediaStatus = client?.mediaStatus if (mediaStatus != null && mediaStatus.queueItemCount > 0) { // Check if there's a previous item in Cast queue val currentItemId = mediaStatus.currentItemId val queueItems = mediaStatus.queueItems val currentIndex = queueItems.indexOfFirst { it.itemId == currentItemId } if (currentIndex > 0) { // There's a previous item in Cast queue, use it client.queuePrev(org.json.JSONObject()) // Ensure local player stays paused musicService.player.pause() return } } // Fall back to loading from MusicService queue val player = musicService.player if (player.hasPreviousMediaItem()) { // Pause first, then seek player.pause() player.seekToPreviousMediaItem() } } private fun startPositionUpdates() { positionUpdateJob?.cancel() positionUpdateJob = scope.launch { while (isActive && _isCasting.value) { remoteMediaClient?.let { client -> _castPosition.value = client.approximateStreamPosition } delay(500) } } } private fun stopPositionUpdates() { positionUpdateJob?.cancel() positionUpdateJob = null } fun release() { stopPositionUpdates() remoteMediaClient?.unregisterCallback(remoteMediaClientCallback) sessionManager?.removeSessionManagerListener(sessionManagerListener, CastSession::class.java) } } ================================================ FILE: app/src/gms/kotlin/com/metrolist/music/ui/component/CastButton.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme 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.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.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.framework.CastContext import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.EnableGoogleCastKey import com.metrolist.music.utils.rememberPreference import timber.log.Timber /** * A Composable Cast button that shows available Cast devices. * Uses the app's MenuState to show a styled bottom sheet. */ @Composable fun CastButton( modifier: Modifier = Modifier, tintColor: Color = MaterialTheme.colorScheme.onSurface, ) { val context = LocalContext.current val playerConnection = LocalPlayerConnection.current val menuState = LocalMenuState.current var castAvailable by remember { mutableStateOf(false) } var mediaRouter by remember { mutableStateOf(null) } var routeSelector by remember { mutableStateOf(null) } var availableRoutes by remember { mutableStateOf>(emptyList()) } val (enableGoogleCast) = rememberPreference( key = EnableGoogleCastKey, defaultValue = true ) // Get cast state from service val castHandler = playerConnection?.service?.castConnectionHandler val isCasting by castHandler?.isCasting?.collectAsState() ?: remember { mutableStateOf(false) } val isConnecting by castHandler?.isConnecting?.collectAsState() ?: remember { mutableStateOf(false) } val castDeviceName by castHandler?.castDeviceName?.collectAsState() ?: remember { mutableStateOf(null) } // Get current media metadata val currentMetadata by playerConnection?.mediaMetadata?.collectAsState() ?: remember { mutableStateOf(null) } // Check if Cast is available and disconnect if disabled while casting LaunchedEffect(enableGoogleCast) { if (!enableGoogleCast) { // Disconnect from Cast if currently casting if (isCasting) { playerConnection?.service?.castConnectionHandler?.disconnect() } castAvailable = false mediaRouter = null routeSelector = null availableRoutes = emptyList() return@LaunchedEffect } try { CastContext.getSharedInstance(context) mediaRouter = MediaRouter.getInstance(context) routeSelector = MediaRouteSelector.Builder() .addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)) .build() // Reinitialize the Cast handler to ensure it's ready playerConnection?.service?.castConnectionHandler?.initialize() castAvailable = true } catch (e: Exception) { Timber.d("Cast not available: ${e.message}") castAvailable = false } } // Listen for route changes to discover devices DisposableEffect(mediaRouter, routeSelector) { val callback = object : MediaRouter.Callback() { override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) { updateRoutes(router, routeSelector) { availableRoutes = it } } override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) { updateRoutes(router, routeSelector) { availableRoutes = it } } override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) { updateRoutes(router, routeSelector) { availableRoutes = it } } } routeSelector?.let { selector -> mediaRouter?.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY) // Initial update updateRoutes(mediaRouter, selector) { availableRoutes = it } } onDispose { mediaRouter?.removeCallback(callback) } } // Show the button if Cast is enabled and SDK is available if (enableGoogleCast && castAvailable) { Box( modifier = modifier ) { // Shadow background for cast button Box( modifier = Modifier .size(56.dp) .align(Alignment.Center) .background( brush = Brush.radialGradient( colors = listOf( Color.Black.copy(alpha = 0.4f), Color.Transparent ) ) ) ) // Cast button Box( contentAlignment = Alignment.Center, modifier = Modifier .size(40.dp) .align(Alignment.Center) .clip(RoundedCornerShape(20.dp)) .clickable { if (currentMetadata == null && !isCasting) { Toast.makeText(context, "Play a song first to cast", Toast.LENGTH_SHORT).show() return@clickable } // Get current connected route if casting val currentRoute = if (isCasting) { mediaRouter?.routes?.find { route -> routeSelector?.let { selector -> route.matchesSelector(selector) && route.isSelected } == true } } else null // Show bottom sheet with cast picker menuState.show { CastPickerSheet( routes = availableRoutes, isConnecting = isConnecting, currentlyConnectedRoute = currentRoute, onRouteSelected = { route -> castHandler?.connectToRoute(route) menuState.dismiss() }, onDisconnect = { castHandler?.disconnect() menuState.dismiss() } ) } } ) { Image( painter = painterResource( if (isCasting) R.drawable.cast_connected else R.drawable.cast ), contentDescription = if (isCasting) "Stop casting" else "Cast", colorFilter = ColorFilter.tint( if (isCasting) MaterialTheme.colorScheme.primary else tintColor ), modifier = Modifier.size(24.dp) ) } } } } private fun updateRoutes( router: MediaRouter?, selector: MediaRouteSelector?, onUpdate: (List) -> Unit ) { if (router == null || selector == null) { onUpdate(emptyList()) return } val routes = router.routes.filter { route -> route.matchesSelector(selector) && !route.isDefault } onUpdate(routes) } ================================================ FILE: app/src/gms/kotlin/com/metrolist/music/ui/component/CastPickerSheet.kt ================================================ package com.metrolist.music.ui.component import androidx.compose.foundation.clickable 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.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator 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.unit.dp import androidx.mediarouter.media.MediaRouter import com.metrolist.music.R @Composable fun CastPickerSheet( routes: List, isConnecting: Boolean, onRouteSelected: (MediaRouter.RouteInfo) -> Unit, onDisconnect: () -> Unit, currentlyConnectedRoute: MediaRouter.RouteInfo?, modifier: Modifier = Modifier ) { Column( modifier = modifier .fillMaxWidth() .padding(bottom = 24.dp) ) { // Header Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 16.dp) ) { Icon( painter = painterResource(R.drawable.cast), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(12.dp)) Text( text = if (currentlyConnectedRoute != null) "Casting" else "Cast to", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) } if (isConnecting) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(vertical = 16.dp) ) { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp ) Spacer(modifier = Modifier.width(16.dp)) Text( text = "Connecting...", style = MaterialTheme.typography.bodyLarge ) } } else if (currentlyConnectedRoute != null) { // Currently connected - show disconnect option Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { onDisconnect() } .padding(vertical = 12.dp) ) { Icon( painter = painterResource(R.drawable.cast_connected), contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = currentlyConnectedRoute.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium ) Text( text = "Connected", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) } Text( text = "Disconnect", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.error ) } } else if (routes.isEmpty()) { // No devices found Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(R.drawable.cast), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "No Cast devices found", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) Text( text = "Make sure your device is on the same Wi-Fi network", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) } } else { // Show available devices LazyColumn { items(routes) { route -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { onRouteSelected(route) } .padding(vertical = 12.dp) ) { Icon( painter = painterResource(R.drawable.cast), contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.width(16.dp)) Text( text = route.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f) ) } } } } } } ================================================ FILE: app/src/izzy/AndroidManifest.xml ================================================ ================================================ FILE: app/src/izzy/kotlin/com/metrolist/music/cast/CastOptionsProvider.kt ================================================ package com.metrolist.music.cast /** * Stub CastOptionsProvider for Izzy builds. * The AndroidManifest reference is removed via manifest merger. */ class CastOptionsProvider ================================================ FILE: app/src/izzy/kotlin/com/metrolist/music/playback/CastConnectionHandler.kt ================================================ package com.metrolist.music.playback import android.content.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** * Stub CastConnectionHandler for Izzy builds. * Cast functionality is not available without Google Play Services. */ class CastConnectionHandler( context: Context, scope: CoroutineScope, musicService: MusicService ) { private val _isCasting = MutableStateFlow(false) val isCasting: StateFlow = _isCasting private val _isConnecting = MutableStateFlow(false) val isConnecting: StateFlow = _isConnecting private val _castDeviceName = MutableStateFlow(null) val castDeviceName: StateFlow = _castDeviceName private val _castPosition = MutableStateFlow(0L) val castPosition: StateFlow = _castPosition private val _castDuration = MutableStateFlow(0L) val castDuration: StateFlow = _castDuration private val _castIsPlaying = MutableStateFlow(false) val castIsPlaying: StateFlow = _castIsPlaying private val _castIsBuffering = MutableStateFlow(false) val castIsBuffering: StateFlow = _castIsBuffering private val _castVolume = MutableStateFlow(1.0f) val castVolume: StateFlow = _castVolume var isSyncingFromCast: Boolean = false private set fun initialize(): Boolean = false fun disconnect() {} fun loadCurrentMedia() {} fun loadMedia(metadata: com.metrolist.music.models.MediaMetadata) {} fun play() {} fun pause() {} fun seekTo(position: Long) {} fun setVolume(volume: Float) {} fun skipToNext() {} fun skipToPrevious() {} fun navigateToMediaIfInQueue(mediaId: String): Boolean = false fun release() {} } ================================================ FILE: app/src/izzy/kotlin/com/metrolist/music/ui/component/CastButton.kt ================================================ package com.metrolist.music.ui.component import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color /** * Stub CastButton for Izzy builds. * Does not render anything - Cast not available without GMS. */ @Composable fun CastButton( modifier: Modifier = Modifier, tintColor: Color = MaterialTheme.colorScheme.onSurface, ) { // No-op: Cast not available in Izzy build } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/assets/po_token.html ================================================ ================================================ FILE: app/src/main/assets/solver/astring.js ================================================ (function(a,b){if("function"==typeof define&&define.amd)define(["exports"],b);else if("undefined"!=typeof exports)b(exports);else{var c={exports:{}};b(c.exports),a.astring=c.exports}})("undefined"==typeof globalThis?"undefined"==typeof self?this:self:globalThis,function(a){"use strict";var b=String.prototype;function c(a,b){if(!(a instanceof b))throw new TypeError("Cannot call a class as a function")}function d(a,b){for(var c,d=0;d":9,"<=":9,">=":9,in:9,instanceof:9,"<<":10,">>":10,">>>":10,"+":11,"-":11,"*":12,"%":12,"/":12,"**":13},p=17;a.NEEDS_PARENTHESES=p;var q={ArrayExpression:20,TaggedTemplateExpression:20,ThisExpression:20,Identifier:20,PrivateIdentifier:20,Literal:18,TemplateLiteral:20,Super:20,SequenceExpression:20,MemberExpression:19,ChainExpression:19,CallExpression:19,NewExpression:19,ArrowFunctionExpression:p,ClassExpression:p,FunctionExpression:p,ObjectExpression:p,UpdateExpression:16,UnaryExpression:15,AwaitExpression:15,BinaryExpression:14,LogicalExpression:13,ConditionalExpression:4,AssignmentExpression:3,YieldExpression:2,RestElement:1};a.EXPRESSIONS_PRECEDENCE=q;var r,s,t,u,v,w,x={Program:function Program(a,b){var c=b.indent.repeat(b.indentLevel),d=b.lineEnd,e=b.writeComments;e&&null!=a.comments&&k(b,a.comments,c,d);for(var f,g=a.body,h=g.length,j=0;j "),"O"===a.body.type[0]?(b.write("("),this.ObjectExpression(a.body,b),b.write(")")):this[a.body.type](a.body,b)},ThisExpression:function ThisExpression(a,b){b.write("this",a)},Super:function Super(a,b){b.write("super",a)},RestElement:t=function(a,b){b.write("..."),this[a.argument.type](a.argument,b)},SpreadElement:t,YieldExpression:function YieldExpression(a,b){b.write(a.delegate?"yield*":"yield"),a.argument&&(b.write(" "),this[a.argument.type](a.argument,b))},AwaitExpression:function AwaitExpression(a,b){b.write("await ",a),h(b,a.argument,a)},TemplateLiteral:function TemplateLiteral(a,b){var c=a.quasis,d=a.expressions;b.write("`");for(var e=d.length,f=0;f { const result = new Uint32Array(69632); let index = 0; let subIndex = 0; while (index < 2571) { const inst = compressed[index++]; if (inst < 0) { subIndex -= inst; } else { let code = compressed[index++]; if (inst & 2) code = lookup[code]; if (inst & 1) { result.fill(code, subIndex, subIndex += compressed[index++]); } else { result[subIndex++] = code; } } } return result; })([-1, 2, 26, 2, 27, 2, 5, -1, 0, 77595648, 3, 44, 2, 3, 0, 14, 2, 63, 2, 64, 3, 0, 3, 0, 3168796671, 0, 4294956992, 2, 1, 2, 0, 2, 41, 3, 0, 4, 0, 4294966523, 3, 0, 4, 2, 16, 2, 65, 2, 0, 0, 4294836735, 0, 3221225471, 0, 4294901942, 2, 66, 0, 134152192, 3, 0, 2, 0, 4294951935, 3, 0, 2, 0, 2683305983, 0, 2684354047, 2, 18, 2, 0, 0, 4294961151, 3, 0, 2, 2, 19, 2, 0, 0, 608174079, 2, 0, 2, 60, 2, 7, 2, 6, 0, 4286611199, 3, 0, 2, 2, 1, 3, 0, 3, 0, 4294901711, 2, 40, 0, 4089839103, 0, 2961209759, 0, 1342439375, 0, 4294543342, 0, 3547201023, 0, 1577204103, 0, 4194240, 0, 4294688750, 2, 2, 0, 80831, 0, 4261478351, 0, 4294549486, 2, 2, 0, 2967484831, 0, 196559, 0, 3594373100, 0, 3288319768, 0, 8469959, 0, 65472, 2, 3, 0, 4093640191, 0, 660618719, 0, 65487, 0, 4294828015, 0, 4092591615, 0, 1616920031, 0, 982991, 2, 3, 2, 0, 0, 2163244511, 0, 4227923919, 0, 4236247022, 2, 71, 0, 4284449919, 0, 851904, 2, 4, 2, 12, 0, 67076095, -1, 2, 72, 0, 1073741743, 0, 4093607775, -1, 0, 50331649, 0, 3265266687, 2, 33, 0, 4294844415, 0, 4278190047, 2, 20, 2, 137, -1, 3, 0, 2, 2, 23, 2, 0, 2, 10, 2, 0, 2, 15, 2, 22, 3, 0, 10, 2, 74, 2, 0, 2, 75, 2, 76, 2, 77, 2, 0, 2, 78, 2, 0, 2, 11, 0, 261632, 2, 25, 3, 0, 2, 2, 13, 2, 4, 3, 0, 18, 2, 79, 2, 5, 3, 0, 2, 2, 80, 0, 2151677951, 2, 29, 2, 9, 0, 909311, 3, 0, 2, 0, 814743551, 2, 49, 0, 67090432, 3, 0, 2, 2, 42, 2, 0, 2, 6, 2, 0, 2, 30, 2, 8, 0, 268374015, 2, 110, 2, 51, 2, 0, 2, 81, 0, 134153215, -1, 2, 7, 2, 0, 2, 8, 0, 2684354559, 0, 67044351, 0, 3221160064, 2, 17, -1, 3, 0, 2, 2, 53, 0, 1046528, 3, 0, 3, 2, 9, 2, 0, 2, 54, 0, 4294960127, 2, 10, 2, 6, 2, 11, 0, 4294377472, 2, 12, 3, 0, 16, 2, 13, 2, 0, 2, 82, 2, 10, 2, 0, 2, 83, 2, 84, 2, 85, 0, 12288, 2, 55, 0, 1048577, 2, 86, 2, 14, -1, 2, 14, 0, 131042, 2, 87, 2, 88, 2, 89, 2, 0, 2, 34, -83, 3, 0, 7, 0, 1046559, 2, 0, 2, 15, 2, 0, 0, 2147516671, 2, 21, 3, 90, 2, 2, 0, -16, 2, 91, 0, 524222462, 2, 4, 2, 0, 0, 4269801471, 2, 4, 3, 0, 2, 2, 28, 2, 16, 3, 0, 2, 2, 17, 2, 0, -1, 2, 18, -16, 3, 0, 206, -2, 3, 0, 692, 2, 73, -1, 2, 18, 2, 10, 3, 0, 8, 2, 93, 2, 133, 2, 0, 0, 3220242431, 3, 0, 3, 2, 19, 2, 94, 2, 95, 3, 0, 2, 2, 96, 2, 0, 2, 97, 2, 46, 2, 0, 0, 4351, 2, 0, 2, 9, 3, 0, 2, 0, 67043391, 0, 3909091327, 2, 0, 2, 24, 2, 9, 2, 20, 3, 0, 2, 0, 67076097, 2, 8, 2, 0, 2, 21, 0, 67059711, 0, 4236247039, 3, 0, 2, 0, 939524103, 0, 8191999, 2, 101, 2, 102, 2, 22, 2, 23, 3, 0, 3, 0, 67057663, 3, 0, 349, 2, 103, 2, 104, 2, 7, -264, 3, 0, 11, 2, 24, 3, 0, 2, 2, 32, -1, 0, 3774349439, 2, 105, 2, 106, 3, 0, 2, 2, 19, 2, 107, 3, 0, 10, 2, 10, 2, 18, 2, 0, 2, 47, 2, 0, 2, 31, 2, 108, 2, 25, 0, 1638399, 0, 57344, 2, 109, 3, 0, 3, 2, 20, 2, 26, 2, 27, 2, 5, 2, 28, 2, 0, 2, 8, 2, 111, -1, 2, 112, 2, 113, 2, 114, -1, 3, 0, 3, 2, 12, -2, 2, 0, 2, 29, -3, 0, 536870912, -4, 2, 20, 2, 0, 2, 36, 0, 1, 2, 0, 2, 67, 2, 6, 2, 12, 2, 10, 2, 0, 2, 115, -1, 3, 0, 4, 2, 10, 2, 23, 2, 116, 2, 7, 2, 0, 2, 117, 2, 0, 2, 118, 2, 119, 2, 120, 2, 0, 2, 9, 3, 0, 9, 2, 21, 2, 30, 2, 31, 2, 121, 2, 122, -2, 2, 123, 2, 124, 2, 30, 2, 21, 2, 8, -2, 2, 125, 2, 30, 2, 32, -2, 2, 0, 2, 39, -2, 0, 4277137519, 0, 2269118463, -1, 3, 20, 2, -1, 2, 33, 2, 38, 2, 0, 3, 30, 2, 2, 35, 2, 19, -3, 3, 0, 2, 2, 34, -1, 2, 0, 2, 35, 2, 0, 2, 35, 2, 0, 2, 48, 2, 0, 0, 4294950463, 2, 37, -7, 2, 0, 0, 203775, 2, 57, 0, 4026531840, 2, 20, 2, 43, 2, 36, 2, 18, 2, 37, 2, 18, 2, 126, 2, 21, 3, 0, 2, 2, 38, 0, 2151677888, 2, 0, 2, 12, 0, 4294901764, 2, 144, 2, 0, 2, 58, 2, 56, 0, 5242879, 3, 0, 2, 0, 402644511, -1, 2, 128, 2, 39, 0, 3, -1, 2, 129, 2, 130, 2, 0, 0, 67045375, 2, 40, 0, 4226678271, 0, 3766565279, 0, 2039759, 2, 132, 2, 41, 0, 1046437, 0, 6, 3, 0, 2, 0, 3288270847, 0, 3, 3, 0, 2, 0, 67043519, -5, 2, 0, 0, 4282384383, 0, 1056964609, -1, 3, 0, 2, 0, 67043345, -1, 2, 0, 2, 42, 2, 23, 2, 50, 2, 11, 2, 61, 2, 38, -5, 2, 0, 2, 12, -3, 3, 0, 2, 0, 2147484671, 2, 134, 0, 4190109695, 2, 52, -2, 2, 135, 0, 4244635647, 0, 27, 2, 0, 2, 8, 2, 43, 2, 0, 2, 68, 2, 18, 2, 0, 2, 42, -6, 2, 0, 2, 45, 2, 59, 2, 44, 2, 45, 2, 46, 2, 47, 0, 8388351, -2, 2, 136, 0, 3028287487, 2, 48, 2, 138, 0, 33259519, 2, 49, -9, 2, 21, 0, 4294836223, 0, 3355443199, 0, 134152199, -2, 2, 69, -2, 3, 0, 28, 2, 32, -3, 3, 0, 3, 2, 17, 3, 0, 6, 2, 50, -81, 2, 18, 3, 0, 2, 2, 36, 3, 0, 33, 2, 25, 2, 30, 3, 0, 124, 2, 12, 3, 0, 18, 2, 38, -213, 2, 0, 2, 32, -54, 3, 0, 17, 2, 42, 2, 8, 2, 23, 2, 0, 2, 8, 2, 23, 2, 51, 2, 0, 2, 21, 2, 52, 2, 139, 2, 25, -13, 2, 0, 2, 53, -6, 3, 0, 2, -4, 3, 0, 2, 0, 4294936575, 2, 0, 0, 4294934783, -2, 0, 196635, 3, 0, 191, 2, 54, 3, 0, 38, 2, 30, 2, 55, 2, 34, -278, 2, 140, 3, 0, 9, 2, 141, 2, 142, 2, 56, 3, 0, 11, 2, 7, -72, 3, 0, 3, 2, 143, 0, 1677656575, -130, 2, 26, -16, 2, 0, 2, 24, 2, 38, -16, 0, 4161266656, 0, 4071, 0, 15360, -4, 2, 57, -13, 3, 0, 2, 2, 58, 2, 0, 2, 145, 2, 146, 2, 62, 2, 0, 2, 147, 2, 148, 2, 149, 3, 0, 10, 2, 150, 2, 151, 2, 22, 3, 58, 2, 3, 152, 2, 3, 59, 2, 0, 4294954999, 2, 0, -16, 2, 0, 2, 92, 2, 0, 0, 2105343, 0, 4160749584, 0, 65534, -34, 2, 8, 2, 154, -6, 0, 4194303871, 0, 4294903771, 2, 0, 2, 60, 2, 100, -3, 2, 0, 0, 1073684479, 0, 17407, -9, 2, 18, 2, 17, 2, 0, 2, 32, -14, 2, 18, 2, 32, -6, 2, 18, 2, 12, -15, 2, 155, 3, 0, 6, 0, 8323103, -1, 3, 0, 2, 2, 61, -37, 2, 62, 2, 156, 2, 157, 2, 158, 2, 159, 2, 160, -105, 2, 26, -32, 3, 0, 1335, -1, 3, 0, 129, 2, 32, 3, 0, 6, 2, 10, 3, 0, 180, 2, 161, 3, 0, 233, 2, 162, 3, 0, 18, 2, 10, -77, 3, 0, 16, 2, 10, -47, 3, 0, 154, 2, 6, 3, 0, 130, 2, 25, -22250, 3, 0, 7, 2, 25, -6130, 3, 5, 2, -1, 0, 69207040, 3, 44, 2, 3, 0, 14, 2, 63, 2, 64, -3, 0, 3168731136, 0, 4294956864, 2, 1, 2, 0, 2, 41, 3, 0, 4, 0, 4294966275, 3, 0, 4, 2, 16, 2, 65, 2, 0, 2, 34, -1, 2, 18, 2, 66, -1, 2, 0, 0, 2047, 0, 4294885376, 3, 0, 2, 0, 3145727, 0, 2617294944, 0, 4294770688, 2, 25, 2, 67, 3, 0, 2, 0, 131135, 2, 98, 0, 70256639, 0, 71303167, 0, 272, 2, 42, 2, 6, 0, 32511, 2, 0, 2, 49, -1, 2, 99, 2, 68, 0, 4278255616, 0, 4294836227, 0, 4294549473, 0, 600178175, 0, 2952806400, 0, 268632067, 0, 4294543328, 0, 57540095, 0, 1577058304, 0, 1835008, 0, 4294688736, 2, 70, 2, 69, 0, 33554435, 2, 131, 2, 70, 0, 2952790016, 0, 131075, 0, 3594373096, 0, 67094296, 2, 69, -1, 0, 4294828000, 0, 603979263, 0, 654311424, 0, 3, 0, 4294828001, 0, 602930687, 0, 1610612736, 0, 393219, 0, 4294828016, 0, 671088639, 0, 2154840064, 0, 4227858435, 0, 4236247008, 2, 71, 2, 38, -1, 2, 4, 0, 917503, 2, 38, -1, 2, 72, 0, 537788335, 0, 4026531935, -1, 0, 1, -1, 2, 33, 2, 73, 0, 7936, -3, 2, 0, 0, 2147485695, 0, 1010761728, 0, 4292984930, 0, 16387, 2, 0, 2, 15, 2, 22, 3, 0, 10, 2, 74, 2, 0, 2, 75, 2, 76, 2, 77, 2, 0, 2, 78, 2, 0, 2, 12, -1, 2, 25, 3, 0, 2, 2, 13, 2, 4, 3, 0, 18, 2, 79, 2, 5, 3, 0, 2, 2, 80, 0, 2147745791, 3, 19, 2, 0, 122879, 2, 0, 2, 9, 0, 276824064, -2, 3, 0, 2, 2, 42, 2, 0, 0, 4294903295, 2, 0, 2, 30, 2, 8, -1, 2, 18, 2, 51, 2, 0, 2, 81, 2, 49, -1, 2, 21, 2, 0, 2, 29, -2, 0, 128, -2, 2, 28, 2, 9, 0, 8160, -1, 2, 127, 0, 4227907585, 2, 0, 2, 37, 2, 0, 2, 50, 0, 4227915776, 2, 10, 2, 6, 2, 11, -1, 0, 74440192, 3, 0, 6, -2, 3, 0, 8, 2, 13, 2, 0, 2, 82, 2, 10, 2, 0, 2, 83, 2, 84, 2, 85, -3, 2, 86, 2, 14, -3, 2, 87, 2, 88, 2, 89, 2, 0, 2, 34, -83, 3, 0, 7, 0, 817183, 2, 0, 2, 15, 2, 0, 0, 33023, 2, 21, 3, 90, 2, -17, 2, 91, 0, 524157950, 2, 4, 2, 0, 2, 92, 2, 4, 2, 0, 2, 22, 2, 28, 2, 16, 3, 0, 2, 2, 17, 2, 0, -1, 2, 18, -16, 3, 0, 206, -2, 3, 0, 692, 2, 73, -1, 2, 18, 2, 10, 3, 0, 8, 2, 93, 0, 3072, 2, 0, 0, 2147516415, 2, 10, 3, 0, 2, 2, 25, 2, 94, 2, 95, 3, 0, 2, 2, 96, 2, 0, 2, 97, 2, 46, 0, 4294965179, 0, 7, 2, 0, 2, 9, 2, 95, 2, 9, -1, 0, 1761345536, 2, 98, 0, 4294901823, 2, 38, 2, 20, 2, 99, 2, 35, 2, 100, 0, 2080440287, 2, 0, 2, 34, 2, 153, 0, 3296722943, 2, 0, 0, 1046675455, 0, 939524101, 0, 1837055, 2, 101, 2, 102, 2, 22, 2, 23, 3, 0, 3, 0, 7, 3, 0, 349, 2, 103, 2, 104, 2, 7, -264, 3, 0, 11, 2, 24, 3, 0, 2, 2, 32, -1, 0, 2700607615, 2, 105, 2, 106, 3, 0, 2, 2, 19, 2, 107, 3, 0, 10, 2, 10, 2, 18, 2, 0, 2, 47, 2, 0, 2, 31, 2, 108, -3, 2, 109, 3, 0, 3, 2, 20, -1, 3, 5, 2, 2, 110, 2, 0, 2, 8, 2, 111, -1, 2, 112, 2, 113, 2, 114, -1, 3, 0, 3, 2, 12, -2, 2, 0, 2, 29, -8, 2, 20, 2, 0, 2, 36, -1, 2, 0, 2, 67, 2, 6, 2, 30, 2, 10, 2, 0, 2, 115, -1, 3, 0, 4, 2, 10, 2, 18, 2, 116, 2, 7, 2, 0, 2, 117, 2, 0, 2, 118, 2, 119, 2, 120, 2, 0, 2, 9, 3, 0, 9, 2, 21, 2, 30, 2, 31, 2, 121, 2, 122, -2, 2, 123, 2, 124, 2, 30, 2, 21, 2, 8, -2, 2, 125, 2, 30, 2, 32, -2, 2, 0, 2, 39, -2, 0, 4277075969, 2, 30, -1, 3, 20, 2, -1, 2, 33, 2, 126, 2, 0, 3, 30, 2, 2, 35, 2, 19, -3, 3, 0, 2, 2, 34, -1, 2, 0, 2, 35, 2, 0, 2, 35, 2, 0, 2, 50, 2, 98, 0, 4294934591, 2, 37, -7, 2, 0, 0, 197631, 2, 57, -1, 2, 20, 2, 43, 2, 37, 2, 18, 0, 3, 2, 18, 2, 126, 2, 21, 2, 127, 2, 54, -1, 0, 2490368, 2, 127, 2, 25, 2, 18, 2, 34, 2, 127, 2, 38, 0, 4294901904, 0, 4718591, 2, 127, 2, 35, 0, 335544350, -1, 2, 128, 0, 2147487743, 0, 1, -1, 2, 129, 2, 130, 2, 8, -1, 2, 131, 2, 70, 0, 3758161920, 0, 3, 2, 132, 0, 12582911, 0, 655360, -1, 2, 0, 2, 29, 0, 2147485568, 0, 3, 2, 0, 2, 25, 0, 176, -5, 2, 0, 2, 17, 0, 251658240, -1, 2, 0, 2, 25, 0, 16, -1, 2, 0, 0, 16779263, -2, 2, 12, -1, 2, 38, -5, 2, 0, 2, 133, -3, 3, 0, 2, 2, 55, 2, 134, 0, 2147549183, 0, 2, -2, 2, 135, 2, 36, 0, 10, 0, 4294965249, 0, 67633151, 0, 4026597376, 2, 0, 0, 536871935, 2, 18, 2, 0, 2, 42, -6, 2, 0, 0, 1, 2, 59, 2, 17, 0, 1, 2, 46, 2, 25, -3, 2, 136, 2, 36, 2, 137, 2, 138, 0, 16778239, -10, 2, 35, 0, 4294836212, 2, 9, -3, 2, 69, -2, 3, 0, 28, 2, 32, -3, 3, 0, 3, 2, 17, 3, 0, 6, 2, 50, -81, 2, 18, 3, 0, 2, 2, 36, 3, 0, 33, 2, 25, 0, 126, 3, 0, 124, 2, 12, 3, 0, 18, 2, 38, -213, 2, 10, -55, 3, 0, 17, 2, 42, 2, 8, 2, 18, 2, 0, 2, 8, 2, 18, 2, 60, 2, 0, 2, 25, 2, 50, 2, 139, 2, 25, -13, 2, 0, 2, 73, -6, 3, 0, 2, -4, 3, 0, 2, 0, 67583, -1, 2, 107, -2, 0, 11, 3, 0, 191, 2, 54, 3, 0, 38, 2, 30, 2, 55, 2, 34, -278, 2, 140, 3, 0, 9, 2, 141, 2, 142, 2, 56, 3, 0, 11, 2, 7, -72, 3, 0, 3, 2, 143, 2, 144, -187, 3, 0, 2, 2, 58, 2, 0, 2, 145, 2, 146, 2, 62, 2, 0, 2, 147, 2, 148, 2, 149, 3, 0, 10, 2, 150, 2, 151, 2, 22, 3, 58, 2, 3, 152, 2, 3, 59, 2, 2, 153, -57, 2, 8, 2, 154, -7, 2, 18, 2, 0, 2, 60, -4, 2, 0, 0, 1065361407, 0, 16384, -9, 2, 18, 2, 60, 2, 0, 2, 133, -14, 2, 18, 2, 133, -6, 2, 18, 0, 81919, -15, 2, 155, 3, 0, 6, 2, 126, -1, 3, 0, 2, 0, 2063, -37, 2, 62, 2, 156, 2, 157, 2, 158, 2, 159, 2, 160, -138, 3, 0, 1335, -1, 3, 0, 129, 2, 32, 3, 0, 6, 2, 10, 3, 0, 180, 2, 161, 3, 0, 233, 2, 162, 3, 0, 18, 2, 10, -77, 3, 0, 16, 2, 10, -47, 3, 0, 154, 2, 6, 3, 0, 130, 2, 25, -28386], [4294967295, 4294967291, 4092460543, 4294828031, 4294967294, 134217726, 4294903807, 268435455, 2147483647, 1048575, 1073741823, 3892314111, 134217727, 1061158911, 536805376, 4294910143, 4294901759, 32767, 4294901760, 262143, 536870911, 8388607, 4160749567, 4294902783, 4294918143, 65535, 67043328, 2281701374, 4294967264, 2097151, 4194303, 255, 67108863, 4294967039, 511, 524287, 131071, 63, 127, 3238002687, 4294549487, 4290772991, 33554431, 4294901888, 4286578687, 67043329, 4294705152, 4294770687, 67043583, 1023, 15, 2047999, 67043343, 67051519, 16777215, 2147483648, 4294902000, 28, 4292870143, 4294966783, 16383, 67047423, 4294967279, 262083, 20511, 41943039, 493567, 4294959104, 603979775, 65536, 602799615, 805044223, 4294965206, 8191, 1031749119, 4294917631, 2134769663, 4286578493, 4282253311, 4294942719, 33540095, 4294905855, 2868854591, 1608515583, 265232348, 534519807, 2147614720, 1060109444, 4093640016, 17376, 2139062143, 224, 4169138175, 4294909951, 4286578688, 4294967292, 4294965759, 535511039, 4294966272, 4294967280, 32768, 8289918, 4294934399, 4294901775, 4294965375, 1602223615, 4294967259, 4294443008, 268369920, 4292804608, 4294967232, 486341884, 4294963199, 3087007615, 1073692671, 4128527, 4279238655, 4294902015, 4160684047, 4290246655, 469499899, 4294967231, 134086655, 4294966591, 2445279231, 3670015, 31, 4294967288, 4294705151, 3221208447, 4294902271, 4294549472, 4294921215, 4095, 4285526655, 4294966527, 4294966143, 64, 4294966719, 3774873592, 1877934080, 262151, 2555904, 536807423, 67043839, 3758096383, 3959414372, 3755993023, 2080374783, 4294835295, 4294967103, 4160749565, 4294934527, 4087, 2016, 2147446655, 184024726, 2862017156, 1593309078, 268434431, 268434414, 4294901763, 4294901761]); const isIDContinue = (code) => (unicodeLookup[(code >>> 5) + 0] >>> code & 31 & 1) !== 0; const isIDStart = (code) => (unicodeLookup[(code >>> 5) + 34816] >>> code & 31 & 1) !== 0; function advanceChar(parser) { parser.column++; return (parser.currentChar = parser.source.charCodeAt(++parser.index)); } function consumePossibleSurrogatePair(parser) { const hi = parser.currentChar; if ((hi & 0xfc00) !== 55296) return 0; const lo = parser.source.charCodeAt(parser.index + 1); if ((lo & 0xfc00) !== 56320) return 0; return 65536 + ((hi & 0x3ff) << 10) + (lo & 0x3ff); } function consumeLineFeed(parser, state) { parser.currentChar = parser.source.charCodeAt(++parser.index); parser.flags |= 1; if ((state & 4) === 0) { parser.column = 0; parser.line++; } } function scanNewLine(parser) { parser.flags |= 1; parser.currentChar = parser.source.charCodeAt(++parser.index); parser.column = 0; parser.line++; } function isExoticECMAScriptWhitespace(ch) { return (ch === 160 || ch === 65279 || ch === 133 || ch === 5760 || (ch >= 8192 && ch <= 8203) || ch === 8239 || ch === 8287 || ch === 12288 || ch === 8201 || ch === 65519); } function toHex(code) { return code < 65 ? code - 48 : (code - 65 + 10) & 0xf; } function convertTokenType(t) { switch (t) { case 134283266: return 'NumericLiteral'; case 134283267: return 'StringLiteral'; case 86021: case 86022: return 'BooleanLiteral'; case 86023: return 'NullLiteral'; case 65540: return 'RegularExpression'; case 67174408: case 67174409: case 131: return 'TemplateLiteral'; default: if ((t & 143360) === 143360) return 'Identifier'; if ((t & 4096) === 4096) return 'Keyword'; return 'Punctuator'; } } const CharTypes = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8 | 1024, 0, 0, 8 | 2048, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8192, 0, 1 | 2, 0, 0, 8192, 0, 0, 0, 256, 0, 256 | 32768, 0, 0, 2 | 16 | 128 | 32 | 64, 2 | 16 | 128 | 32 | 64, 2 | 16 | 32 | 64, 2 | 16 | 32 | 64, 2 | 16 | 32 | 64, 2 | 16 | 32 | 64, 2 | 16 | 32 | 64, 2 | 16 | 32 | 64, 2 | 16 | 512 | 64, 2 | 16 | 512 | 64, 0, 0, 16384, 0, 0, 0, 0, 1 | 2 | 64, 1 | 2 | 64, 1 | 2 | 64, 1 | 2 | 64, 1 | 2 | 64, 1 | 2 | 64, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 1 | 2, 0, 1, 0, 0, 1 | 2 | 4096, 0, 1 | 2 | 4 | 64, 1 | 2 | 4 | 64, 1 | 2 | 4 | 64, 1 | 2 | 4 | 64, 1 | 2 | 4 | 64, 1 | 2 | 4 | 64, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 1 | 2 | 4, 16384, 0, 0, 0, 0 ]; const isIdStart = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0 ]; const isIdPart = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0 ]; function isIdentifierStart(code) { return code <= 0x7F ? isIdStart[code] > 0 : isIDStart(code); } function isIdentifierPart(code) { return code <= 0x7F ? isIdPart[code] > 0 : isIDContinue(code) || (code === 8204 || code === 8205); } const CommentTypes = ['SingleLine', 'MultiLine', 'HTMLOpen', 'HTMLClose', 'HashbangComment']; function skipHashBang(parser) { const { source } = parser; if (parser.currentChar === 35 && source.charCodeAt(parser.index + 1) === 33) { advanceChar(parser); advanceChar(parser); skipSingleLineComment(parser, source, 0, 4, parser.tokenStart); } } function skipSingleHTMLComment(parser, source, state, context, type, start) { if (context & 2) parser.report(0); return skipSingleLineComment(parser, source, state, type, start); } function skipSingleLineComment(parser, source, state, type, start) { const { index } = parser; parser.tokenIndex = parser.index; parser.tokenLine = parser.line; parser.tokenColumn = parser.column; while (parser.index < parser.end) { if (CharTypes[parser.currentChar] & 8) { const isCR = parser.currentChar === 13; scanNewLine(parser); if (isCR && parser.index < parser.end && parser.currentChar === 10) parser.currentChar = source.charCodeAt(++parser.index); break; } else if ((parser.currentChar ^ 8232) <= 1) { scanNewLine(parser); break; } advanceChar(parser); parser.tokenIndex = parser.index; parser.tokenLine = parser.line; parser.tokenColumn = parser.column; } if (parser.options.onComment) { const loc = { start: { line: start.line, column: start.column, }, end: { line: parser.tokenLine, column: parser.tokenColumn, }, }; parser.options.onComment(CommentTypes[type & 0xff], source.slice(index, parser.tokenIndex), start.index, parser.tokenIndex, loc); } return state | 1; } function skipMultiLineComment(parser, source, state) { const { index } = parser; while (parser.index < parser.end) { if (parser.currentChar < 0x2b) { let skippedOneAsterisk = false; while (parser.currentChar === 42) { if (!skippedOneAsterisk) { state &= -5; skippedOneAsterisk = true; } if (advanceChar(parser) === 47) { advanceChar(parser); if (parser.options.onComment) { const loc = { start: { line: parser.tokenLine, column: parser.tokenColumn, }, end: { line: parser.line, column: parser.column, }, }; parser.options.onComment(CommentTypes[1 & 0xff], source.slice(index, parser.index - 2), index - 2, parser.index, loc); } parser.tokenIndex = parser.index; parser.tokenLine = parser.line; parser.tokenColumn = parser.column; return state; } } if (skippedOneAsterisk) { continue; } if (CharTypes[parser.currentChar] & 8) { if (parser.currentChar === 13) { state |= 1 | 4; scanNewLine(parser); } else { consumeLineFeed(parser, state); state = (state & -5) | 1; } } else { advanceChar(parser); } } else if ((parser.currentChar ^ 8232) <= 1) { state = (state & -5) | 1; scanNewLine(parser); } else { state &= -5; advanceChar(parser); } } parser.report(18); } var RegexState; (function (RegexState) { RegexState[RegexState["Empty"] = 0] = "Empty"; RegexState[RegexState["Escape"] = 1] = "Escape"; RegexState[RegexState["Class"] = 2] = "Class"; })(RegexState || (RegexState = {})); var RegexFlags; (function (RegexFlags) { RegexFlags[RegexFlags["Empty"] = 0] = "Empty"; RegexFlags[RegexFlags["IgnoreCase"] = 1] = "IgnoreCase"; RegexFlags[RegexFlags["Global"] = 2] = "Global"; RegexFlags[RegexFlags["Multiline"] = 4] = "Multiline"; RegexFlags[RegexFlags["Unicode"] = 16] = "Unicode"; RegexFlags[RegexFlags["Sticky"] = 8] = "Sticky"; RegexFlags[RegexFlags["DotAll"] = 32] = "DotAll"; RegexFlags[RegexFlags["Indices"] = 64] = "Indices"; RegexFlags[RegexFlags["UnicodeSets"] = 128] = "UnicodeSets"; })(RegexFlags || (RegexFlags = {})); function scanRegularExpression(parser) { const bodyStart = parser.index; let preparseState = RegexState.Empty; loop: while (true) { const ch = parser.currentChar; advanceChar(parser); if (preparseState & RegexState.Escape) { preparseState &= ~RegexState.Escape; } else { switch (ch) { case 47: if (!preparseState) break loop; else break; case 92: preparseState |= RegexState.Escape; break; case 91: preparseState |= RegexState.Class; break; case 93: preparseState &= RegexState.Escape; break; } } if (ch === 13 || ch === 10 || ch === 8232 || ch === 8233) { parser.report(34); } if (parser.index >= parser.source.length) { return parser.report(34); } } const bodyEnd = parser.index - 1; let mask = RegexFlags.Empty; let char = parser.currentChar; const { index: flagStart } = parser; while (isIdentifierPart(char)) { switch (char) { case 103: if (mask & RegexFlags.Global) parser.report(36, 'g'); mask |= RegexFlags.Global; break; case 105: if (mask & RegexFlags.IgnoreCase) parser.report(36, 'i'); mask |= RegexFlags.IgnoreCase; break; case 109: if (mask & RegexFlags.Multiline) parser.report(36, 'm'); mask |= RegexFlags.Multiline; break; case 117: if (mask & RegexFlags.Unicode) parser.report(36, 'u'); if (mask & RegexFlags.UnicodeSets) parser.report(36, 'vu'); mask |= RegexFlags.Unicode; break; case 118: if (mask & RegexFlags.Unicode) parser.report(36, 'uv'); if (mask & RegexFlags.UnicodeSets) parser.report(36, 'v'); mask |= RegexFlags.UnicodeSets; break; case 121: if (mask & RegexFlags.Sticky) parser.report(36, 'y'); mask |= RegexFlags.Sticky; break; case 115: if (mask & RegexFlags.DotAll) parser.report(36, 's'); mask |= RegexFlags.DotAll; break; case 100: if (mask & RegexFlags.Indices) parser.report(36, 'd'); mask |= RegexFlags.Indices; break; default: parser.report(35); } char = advanceChar(parser); } const flags = parser.source.slice(flagStart, parser.index); const pattern = parser.source.slice(bodyStart, bodyEnd); parser.tokenRegExp = { pattern, flags }; if (parser.options.raw) parser.tokenRaw = parser.source.slice(parser.tokenIndex, parser.index); parser.tokenValue = validate(parser, pattern, flags); return 65540; } function validate(parser, pattern, flags) { try { return new RegExp(pattern, flags); } catch { try { new RegExp(pattern, flags); return null; } catch { parser.report(34); } } } function scanString(parser, context, quote) { const { index: start } = parser; let ret = ''; let char = advanceChar(parser); let marker = parser.index; while ((CharTypes[char] & 8) === 0) { if (char === quote) { ret += parser.source.slice(marker, parser.index); advanceChar(parser); if (parser.options.raw) parser.tokenRaw = parser.source.slice(start, parser.index); parser.tokenValue = ret; return 134283267; } if ((char & 8) === 8 && char === 92) { ret += parser.source.slice(marker, parser.index); char = advanceChar(parser); if (char < 0x7f || char === 8232 || char === 8233) { const code = parseEscape(parser, context, char); if (code >= 0) ret += String.fromCodePoint(code); else handleStringError(parser, code, 0); } else { ret += String.fromCodePoint(char); } marker = parser.index + 1; } else if (char === 8232 || char === 8233) { parser.column = -1; parser.line++; } if (parser.index >= parser.end) parser.report(16); char = advanceChar(parser); } parser.report(16); } function parseEscape(parser, context, first, isTemplate = 0) { switch (first) { case 98: return 8; case 102: return 12; case 114: return 13; case 110: return 10; case 116: return 9; case 118: return 11; case 13: { if (parser.index < parser.end) { const nextChar = parser.source.charCodeAt(parser.index + 1); if (nextChar === 10) { parser.index = parser.index + 1; parser.currentChar = nextChar; } } } case 10: case 8232: case 8233: parser.column = -1; parser.line++; return -1; case 48: case 49: case 50: case 51: { let code = first - 48; let index = parser.index + 1; let column = parser.column + 1; if (index < parser.end) { const next = parser.source.charCodeAt(index); if ((CharTypes[next] & 32) === 0) { if (code !== 0 || CharTypes[next] & 512) { if (context & 1 || isTemplate) return -2; parser.flags |= 64; } } else if (context & 1 || isTemplate) { return -2; } else { parser.currentChar = next; code = (code << 3) | (next - 48); index++; column++; if (index < parser.end) { const next = parser.source.charCodeAt(index); if (CharTypes[next] & 32) { parser.currentChar = next; code = (code << 3) | (next - 48); index++; column++; } } parser.flags |= 64; } parser.index = index - 1; parser.column = column - 1; } return code; } case 52: case 53: case 54: case 55: { if (isTemplate || context & 1) return -2; let code = first - 48; const index = parser.index + 1; const column = parser.column + 1; if (index < parser.end) { const next = parser.source.charCodeAt(index); if (CharTypes[next] & 32) { code = (code << 3) | (next - 48); parser.currentChar = next; parser.index = index; parser.column = column; } } parser.flags |= 64; return code; } case 120: { const ch1 = advanceChar(parser); if ((CharTypes[ch1] & 64) === 0) return -4; const hi = toHex(ch1); const ch2 = advanceChar(parser); if ((CharTypes[ch2] & 64) === 0) return -4; const lo = toHex(ch2); return (hi << 4) | lo; } case 117: { const ch = advanceChar(parser); if (parser.currentChar === 123) { let code = 0; while ((CharTypes[advanceChar(parser)] & 64) !== 0) { code = (code << 4) | toHex(parser.currentChar); if (code > 1114111) return -5; } if (parser.currentChar < 1 || parser.currentChar !== 125) { return -4; } return code; } else { if ((CharTypes[ch] & 64) === 0) return -4; const ch2 = parser.source.charCodeAt(parser.index + 1); if ((CharTypes[ch2] & 64) === 0) return -4; const ch3 = parser.source.charCodeAt(parser.index + 2); if ((CharTypes[ch3] & 64) === 0) return -4; const ch4 = parser.source.charCodeAt(parser.index + 3); if ((CharTypes[ch4] & 64) === 0) return -4; parser.index += 3; parser.column += 3; parser.currentChar = parser.source.charCodeAt(parser.index); return (toHex(ch) << 12) | (toHex(ch2) << 8) | (toHex(ch3) << 4) | toHex(ch4); } } case 56: case 57: if (isTemplate || !parser.options.webcompat || context & 1) return -3; parser.flags |= 4096; default: return first; } } function handleStringError(parser, code, isTemplate) { switch (code) { case -1: return; case -2: parser.report(isTemplate ? 2 : 1); case -3: parser.report(isTemplate ? 3 : 14); case -4: parser.report(7); case -5: parser.report(104); } } function scanTemplate(parser, context) { const { index: start } = parser; let token = 67174409; let ret = ''; let char = advanceChar(parser); while (char !== 96) { if (char === 36 && parser.source.charCodeAt(parser.index + 1) === 123) { advanceChar(parser); token = 67174408; break; } else if (char === 92) { char = advanceChar(parser); if (char > 0x7e) { ret += String.fromCodePoint(char); } else { const { index, line, column } = parser; const code = parseEscape(parser, context | 1, char, 1); if (code >= 0) { ret += String.fromCodePoint(code); } else if (code !== -1 && context & 64) { parser.index = index; parser.line = line; parser.column = column; ret = null; char = scanBadTemplate(parser, char); if (char < 0) token = 67174408; break; } else { handleStringError(parser, code, 1); } } } else if (parser.index < parser.end) { if (char === 13 && parser.source.charCodeAt(parser.index) === 10) { ret += String.fromCodePoint(char); parser.currentChar = parser.source.charCodeAt(++parser.index); } if (((char & 83) < 3 && char === 10) || (char ^ 8232) <= 1) { parser.column = -1; parser.line++; } ret += String.fromCodePoint(char); } if (parser.index >= parser.end) parser.report(17); char = advanceChar(parser); } advanceChar(parser); parser.tokenValue = ret; parser.tokenRaw = parser.source.slice(start + 1, parser.index - (token === 67174409 ? 1 : 2)); return token; } function scanBadTemplate(parser, ch) { while (ch !== 96) { switch (ch) { case 36: { const index = parser.index + 1; if (index < parser.end && parser.source.charCodeAt(index) === 123) { parser.index = index; parser.column++; return -ch; } break; } case 10: case 8232: case 8233: parser.column = -1; parser.line++; } if (parser.index >= parser.end) parser.report(17); ch = advanceChar(parser); } return ch; } function scanTemplateTail(parser, context) { if (parser.index >= parser.end) parser.report(0); parser.index--; parser.column--; return scanTemplate(parser, context); } const errorMessages = { [0]: 'Unexpected token', [30]: "Unexpected token: '%0'", [1]: 'Octal escape sequences are not allowed in strict mode', [2]: 'Octal escape sequences are not allowed in template strings', [3]: '\\8 and \\9 are not allowed in template strings', [4]: 'Private identifier #%0 is not defined', [5]: 'Illegal Unicode escape sequence', [6]: 'Invalid code point %0', [7]: 'Invalid hexadecimal escape sequence', [9]: 'Octal literals are not allowed in strict mode', [8]: 'Decimal integer literals with a leading zero are forbidden in strict mode', [10]: 'Expected number in radix %0', [151]: 'Invalid left-hand side assignment to a destructible right-hand side', [11]: 'Non-number found after exponent indicator', [12]: 'Invalid BigIntLiteral', [13]: 'No identifiers allowed directly after numeric literal', [14]: 'Escapes \\8 or \\9 are not syntactically valid escapes', [15]: 'Escapes \\8 or \\9 are not allowed in strict mode', [16]: 'Unterminated string literal', [17]: 'Unterminated template literal', [18]: 'Multiline comment was not closed properly', [19]: 'The identifier contained dynamic unicode escape that was not closed', [20]: "Illegal character '%0'", [21]: 'Missing hexadecimal digits', [22]: 'Invalid implicit octal', [23]: 'Invalid line break in string literal', [24]: 'Only unicode escapes are legal in identifier names', [25]: "Expected '%0'", [26]: 'Invalid left-hand side in assignment', [27]: 'Invalid left-hand side in async arrow', [28]: 'Calls to super must be in the "constructor" method of a class expression or class declaration that has a superclass', [29]: 'Member access on super must be in a method', [31]: 'Await expression not allowed in formal parameter', [32]: 'Yield expression not allowed in formal parameter', [95]: "Unexpected token: 'escaped keyword'", [33]: 'Unary expressions as the left operand of an exponentiation expression must be disambiguated with parentheses', [123]: 'Async functions can only be declared at the top level or inside a block', [34]: 'Unterminated regular expression', [35]: 'Unexpected regular expression flag', [36]: "Duplicate regular expression flag '%0'", [37]: '%0 functions must have exactly %1 argument%2', [38]: 'Setter function argument must not be a rest parameter', [39]: '%0 declaration must have a name in this context', [40]: 'Function name may not contain any reserved words or be eval or arguments in strict mode', [41]: 'The rest operator is missing an argument', [42]: 'A getter cannot be a generator', [43]: 'A setter cannot be a generator', [44]: 'A computed property name must be followed by a colon or paren', [134]: 'Object literal keys that are strings or numbers must be a method or have a colon', [46]: 'Found `* async x(){}` but this should be `async * x(){}`', [45]: 'Getters and setters can not be generators', [47]: "'%0' can not be generator method", [48]: "No line break is allowed after '=>'", [49]: 'The left-hand side of the arrow can only be destructed through assignment', [50]: 'The binding declaration is not destructible', [51]: 'Async arrow can not be followed by new expression', [52]: "Classes may not have a static property named 'prototype'", [53]: 'Class constructor may not be a %0', [54]: 'Duplicate constructor method in class', [55]: 'Invalid increment/decrement operand', [56]: 'Invalid use of `new` keyword on an increment/decrement expression', [57]: '`=>` is an invalid assignment target', [58]: 'Rest element may not have a trailing comma', [59]: 'Missing initializer in %0 declaration', [60]: "'for-%0' loop head declarations can not have an initializer", [61]: 'Invalid left-hand side in for-%0 loop: Must have a single binding', [62]: 'Invalid shorthand property initializer', [63]: 'Property name __proto__ appears more than once in object literal', [64]: 'Let is disallowed as a lexically bound name', [65]: "Invalid use of '%0' inside new expression", [66]: "Illegal 'use strict' directive in function with non-simple parameter list", [67]: 'Identifier "let" disallowed as left-hand side expression in strict mode', [68]: 'Illegal continue statement', [69]: 'Illegal break statement', [70]: 'Cannot have `let[...]` as a var name in strict mode', [71]: 'Invalid destructuring assignment target', [72]: 'Rest parameter may not have a default initializer', [73]: 'The rest argument must the be last parameter', [74]: 'Invalid rest argument', [76]: 'In strict mode code, functions can only be declared at top level or inside a block', [77]: 'In non-strict mode code, functions can only be declared at top level, inside a block, or as the body of an if statement', [78]: 'Without web compatibility enabled functions can not be declared at top level, inside a block, or as the body of an if statement', [79]: "Class declaration can't appear in single-statement context", [80]: 'Invalid left-hand side in for-%0', [81]: 'Invalid assignment in for-%0', [82]: 'for await (... of ...) is only valid in async functions and async generators', [83]: 'The first token after the template expression should be a continuation of the template', [85]: '`let` declaration not allowed here and `let` cannot be a regular var name in strict mode', [84]: '`let \n [` is a restricted production at the start of a statement', [86]: 'Catch clause requires exactly one parameter, not more (and no trailing comma)', [87]: 'Catch clause parameter does not support default values', [88]: 'Missing catch or finally after try', [89]: 'More than one default clause in switch statement', [90]: 'Illegal newline after throw', [91]: 'Strict mode code may not include a with statement', [92]: 'Illegal return statement', [93]: 'The left hand side of the for-header binding declaration is not destructible', [94]: 'new.target only allowed within functions or static blocks', [96]: "'#' not followed by identifier", [102]: 'Invalid keyword', [101]: "Can not use 'let' as a class name", [100]: "'A lexical declaration can't define a 'let' binding", [99]: 'Can not use `let` as variable name in strict mode', [97]: "'%0' may not be used as an identifier in this context", [98]: 'Await is only valid in async functions', [103]: 'The %0 keyword can only be used with the module goal', [104]: 'Unicode codepoint must not be greater than 0x10FFFF', [105]: '%0 source must be string', [106]: 'Only a identifier or string can be used to indicate alias', [107]: "Only '*' or '{...}' can be imported after default", [108]: 'Trailing decorator may be followed by method', [109]: "Decorators can't be used with a constructor", [110]: 'Can not use `await` as identifier in module or async func', [111]: 'Can not use `await` as identifier in module', [112]: 'HTML comments are only allowed with web compatibility (Annex B)', [113]: "The identifier 'let' must not be in expression position in strict mode", [114]: 'Cannot assign to `eval` and `arguments` in strict mode', [115]: "The left-hand side of a for-of loop may not start with 'let'", [116]: 'Block body arrows can not be immediately invoked without a group', [117]: 'Block body arrows can not be immediately accessed without a group', [118]: 'Unexpected strict mode reserved word', [119]: 'Unexpected eval or arguments in strict mode', [120]: 'Decorators must not be followed by a semicolon', [121]: 'Calling delete on expression not allowed in strict mode', [122]: 'Pattern can not have a tail', [124]: 'Can not have a `yield` expression on the left side of a ternary', [125]: 'An arrow function can not have a postfix update operator', [126]: 'Invalid object literal key character after generator star', [127]: 'Private fields can not be deleted', [129]: 'Classes may not have a field called constructor', [128]: 'Classes may not have a private element named constructor', [130]: 'A class field initializer or static block may not contain arguments', [131]: 'Generators can only be declared at the top level or inside a block', [132]: 'Async methods are a restricted production and cannot have a newline following it', [133]: 'Unexpected character after object literal property name', [135]: 'Invalid key token', [136]: "Label '%0' has already been declared", [137]: 'continue statement must be nested within an iteration statement', [138]: "Undefined label '%0'", [139]: 'Trailing comma is disallowed inside import(...) arguments', [140]: 'Invalid binding in JSON import', [141]: 'import() requires exactly one argument', [142]: 'Cannot use new with import(...)', [143]: '... is not allowed in import()', [144]: "Expected '=>'", [145]: "Duplicate binding '%0'", [146]: 'Duplicate private identifier #%0', [147]: "Cannot export a duplicate name '%0'", [150]: 'Duplicate %0 for-binding', [148]: "Exported binding '%0' needs to refer to a top-level declared variable", [149]: 'Unexpected private field', [153]: 'Numeric separators are not allowed at the end of numeric literals', [152]: 'Only one underscore is allowed as numeric separator', [154]: 'JSX value should be either an expression or a quoted JSX text', [155]: 'Expected corresponding JSX closing tag for %0', [156]: 'Adjacent JSX elements must be wrapped in an enclosing tag', [157]: "JSX attributes must only be assigned a non-empty 'expression'", [158]: "'%0' has already been declared", [159]: "'%0' shadowed a catch clause binding", [160]: 'Dot property must be an identifier', [161]: 'Encountered invalid input after spread/rest argument', [162]: 'Catch without try', [163]: 'Finally without try', [164]: 'Expected corresponding closing tag for JSX fragment', [165]: 'Coalescing and logical operators used together in the same expression must be disambiguated with parentheses', [166]: 'Invalid tagged template on optional chain', [167]: 'Invalid optional chain from super property', [168]: 'Invalid optional chain from new expression', [169]: 'Cannot use "import.meta" outside a module', [170]: 'Leading decorators must be attached to a class declaration', [171]: 'An export name cannot include a lone surrogate, found %0', [172]: 'A string literal cannot be used as an exported binding without `from`', [173]: "Private fields can't be accessed on super", [174]: "The only valid meta property for import is 'import.meta'", [175]: "'import.meta' must not contain escaped characters", [176]: 'cannot use "await" as identifier inside an async function', [177]: 'cannot use "await" in static blocks', }; class ParseError extends SyntaxError { start; end; range; loc; description; constructor(start, end, type, ...params) { const description = errorMessages[type].replace(/%(\d+)/g, (_, i) => params[i]); const message = '[' + start.line + ':' + start.column + '-' + end.line + ':' + end.column + ']: ' + description; super(message); this.start = start.index; this.end = end.index; this.range = [start.index, end.index]; this.loc = { start: { line: start.line, column: start.column }, end: { line: end.line, column: end.column }, }; this.description = description; } } function scanNumber(parser, context, kind) { let char = parser.currentChar; let value = 0; let digit = 9; let atStart = kind & 64 ? 0 : 1; let digits = 0; let allowSeparator = 0; if (kind & 64) { value = '.' + scanDecimalDigitsOrSeparator(parser, char); char = parser.currentChar; if (char === 110) parser.report(12); } else { if (char === 48) { char = advanceChar(parser); if ((char | 32) === 120) { kind = 8 | 128; char = advanceChar(parser); while (CharTypes[char] & (64 | 4096)) { if (char === 95) { if (!allowSeparator) parser.report(152); allowSeparator = 0; char = advanceChar(parser); continue; } allowSeparator = 1; value = value * 0x10 + toHex(char); digits++; char = advanceChar(parser); } if (digits === 0 || !allowSeparator) { parser.report(digits === 0 ? 21 : 153); } } else if ((char | 32) === 111) { kind = 4 | 128; char = advanceChar(parser); while (CharTypes[char] & (32 | 4096)) { if (char === 95) { if (!allowSeparator) { parser.report(152); } allowSeparator = 0; char = advanceChar(parser); continue; } allowSeparator = 1; value = value * 8 + (char - 48); digits++; char = advanceChar(parser); } if (digits === 0 || !allowSeparator) { parser.report(digits === 0 ? 0 : 153); } } else if ((char | 32) === 98) { kind = 2 | 128; char = advanceChar(parser); while (CharTypes[char] & (128 | 4096)) { if (char === 95) { if (!allowSeparator) { parser.report(152); } allowSeparator = 0; char = advanceChar(parser); continue; } allowSeparator = 1; value = value * 2 + (char - 48); digits++; char = advanceChar(parser); } if (digits === 0 || !allowSeparator) { parser.report(digits === 0 ? 0 : 153); } } else if (CharTypes[char] & 32) { if (context & 1) parser.report(1); kind = 1; while (CharTypes[char] & 16) { if (CharTypes[char] & 512) { kind = 32; atStart = 0; break; } value = value * 8 + (char - 48); char = advanceChar(parser); } } else if (CharTypes[char] & 512) { if (context & 1) parser.report(1); parser.flags |= 64; kind = 32; } else if (char === 95) { parser.report(0); } } if (kind & 48) { if (atStart) { while (digit >= 0 && CharTypes[char] & (16 | 4096)) { if (char === 95) { char = advanceChar(parser); if (char === 95 || kind & 32) { throw new ParseError(parser.currentLocation, { index: parser.index + 1, line: parser.line, column: parser.column }, 152); } allowSeparator = 1; continue; } allowSeparator = 0; value = 10 * value + (char - 48); char = advanceChar(parser); --digit; } if (allowSeparator) { throw new ParseError(parser.currentLocation, { index: parser.index + 1, line: parser.line, column: parser.column }, 153); } if (digit >= 0 && !isIdentifierStart(char) && char !== 46) { parser.tokenValue = value; if (parser.options.raw) parser.tokenRaw = parser.source.slice(parser.tokenIndex, parser.index); return 134283266; } } value += scanDecimalDigitsOrSeparator(parser, char); char = parser.currentChar; if (char === 46) { if (advanceChar(parser) === 95) parser.report(0); kind = 64; value += '.' + scanDecimalDigitsOrSeparator(parser, parser.currentChar); char = parser.currentChar; } } } const end = parser.index; let isBigInt = 0; if (char === 110 && kind & 128) { isBigInt = 1; char = advanceChar(parser); } else { if ((char | 32) === 101) { char = advanceChar(parser); if (CharTypes[char] & 256) char = advanceChar(parser); const { index } = parser; if ((CharTypes[char] & 16) === 0) parser.report(11); value += parser.source.substring(end, index) + scanDecimalDigitsOrSeparator(parser, char); char = parser.currentChar; } } if ((parser.index < parser.end && CharTypes[char] & 16) || isIdentifierStart(char)) { parser.report(13); } if (isBigInt) { parser.tokenRaw = parser.source.slice(parser.tokenIndex, parser.index); parser.tokenValue = BigInt(parser.tokenRaw.slice(0, -1).replaceAll('_', '')); return 134283388; } parser.tokenValue = kind & (1 | 2 | 8 | 4) ? value : kind & 32 ? parseFloat(parser.source.substring(parser.tokenIndex, parser.index)) : +value; if (parser.options.raw) parser.tokenRaw = parser.source.slice(parser.tokenIndex, parser.index); return 134283266; } function scanDecimalDigitsOrSeparator(parser, char) { let allowSeparator = 0; let start = parser.index; let ret = ''; while (CharTypes[char] & (16 | 4096)) { if (char === 95) { const { index } = parser; char = advanceChar(parser); if (char === 95) { throw new ParseError(parser.currentLocation, { index: parser.index + 1, line: parser.line, column: parser.column }, 152); } allowSeparator = 1; ret += parser.source.substring(start, index); start = parser.index; continue; } allowSeparator = 0; char = advanceChar(parser); } if (allowSeparator) { throw new ParseError(parser.currentLocation, { index: parser.index + 1, line: parser.line, column: parser.column }, 153); } return ret + parser.source.substring(start, parser.index); } const KeywordDescTable = [ 'end of source', 'identifier', 'number', 'string', 'regular expression', 'false', 'true', 'null', 'template continuation', 'template tail', '=>', '(', '{', '.', '...', '}', ')', ';', ',', '[', ']', ':', '?', '\'', '"', '++', '--', '=', '<<=', '>>=', '>>>=', '**=', '+=', '-=', '*=', '/=', '%=', '^=', '|=', '&=', '||=', '&&=', '??=', 'typeof', 'delete', 'void', '!', '~', '+', '-', 'in', 'instanceof', '*', '%', '/', '**', '&&', '||', '===', '!==', '==', '!=', '<=', '>=', '<', '>', '<<', '>>', '>>>', '&', '|', '^', 'var', 'let', 'const', 'break', 'case', 'catch', 'class', 'continue', 'debugger', 'default', 'do', 'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'while', 'with', 'implements', 'interface', 'package', 'private', 'protected', 'public', 'static', 'yield', 'as', 'async', 'await', 'constructor', 'get', 'set', 'accessor', 'from', 'of', 'enum', 'eval', 'arguments', 'escaped keyword', 'escaped future reserved keyword', 'reserved if strict', '#', 'BigIntLiteral', '??', '?.', 'WhiteSpace', 'Illegal', 'LineTerminator', 'PrivateField', 'Template', '@', 'target', 'meta', 'LineFeed', 'Escaped', 'JSXText' ]; const descKeywordTable = { this: 86111, function: 86104, if: 20569, return: 20572, var: 86088, else: 20563, for: 20567, new: 86107, in: 8673330, typeof: 16863275, while: 20578, case: 20556, break: 20555, try: 20577, catch: 20557, delete: 16863276, throw: 86112, switch: 86110, continue: 20559, default: 20561, instanceof: 8411187, do: 20562, void: 16863277, finally: 20566, async: 209005, await: 209006, class: 86094, const: 86090, constructor: 12399, debugger: 20560, export: 20564, extends: 20565, false: 86021, from: 209011, get: 209008, implements: 36964, import: 86106, interface: 36965, let: 241737, null: 86023, of: 471156, package: 36966, private: 36967, protected: 36968, public: 36969, set: 209009, static: 36970, super: 86109, true: 86022, with: 20579, yield: 241771, enum: 86133, eval: 537079926, as: 77932, arguments: 537079927, target: 209029, meta: 209030, accessor: 12402, }; function matchOrInsertSemicolon(parser, context) { if ((parser.flags & 1) === 0 && (parser.getToken() & 1048576) !== 1048576) { parser.report(30, KeywordDescTable[parser.getToken() & 255]); } if (!consumeOpt(parser, context, 1074790417)) { parser.options.onInsertedSemicolon?.(parser.startIndex); } } function isValidStrictMode(parser, index, tokenIndex, tokenValue) { if (index - tokenIndex < 13 && tokenValue === 'use strict') { if ((parser.getToken() & 1048576) === 1048576 || parser.flags & 1) { return 1; } } return 0; } function optionalBit(parser, context, t) { if (parser.getToken() !== t) return 0; nextToken(parser, context); return 1; } function consumeOpt(parser, context, t) { if (parser.getToken() !== t) return false; nextToken(parser, context); return true; } function consume(parser, context, t) { if (parser.getToken() !== t) parser.report(25, KeywordDescTable[t & 255]); nextToken(parser, context); } function reinterpretToPattern(parser, node) { switch (node.type) { case 'ArrayExpression': { node.type = 'ArrayPattern'; const { elements } = node; for (let i = 0, n = elements.length; i < n; ++i) { const element = elements[i]; if (element) reinterpretToPattern(parser, element); } return; } case 'ObjectExpression': { node.type = 'ObjectPattern'; const { properties } = node; for (let i = 0, n = properties.length; i < n; ++i) { reinterpretToPattern(parser, properties[i]); } return; } case 'AssignmentExpression': node.type = 'AssignmentPattern'; if (node.operator !== '=') parser.report(71); delete node.operator; reinterpretToPattern(parser, node.left); return; case 'Property': reinterpretToPattern(parser, node.value); return; case 'SpreadElement': node.type = 'RestElement'; reinterpretToPattern(parser, node.argument); } } function validateBindingIdentifier(parser, context, kind, t, skipEvalArgCheck) { if (context & 1) { if ((t & 36864) === 36864) { parser.report(118); } if (!skipEvalArgCheck && (t & 537079808) === 537079808) { parser.report(119); } } if ((t & 20480) === 20480 || t === -2147483528) { parser.report(102); } if (kind & (8 | 16) && (t & 255) === (241737 & 255)) { parser.report(100); } if (context & (2048 | 2) && t === 209006) { parser.report(110); } if (context & (1024 | 1) && t === 241771) { parser.report(97, 'yield'); } } function validateFunctionName(parser, context, t) { if (context & 1) { if ((t & 36864) === 36864) { parser.report(118); } if ((t & 537079808) === 537079808) { parser.report(119); } if (t === -2147483527) { parser.report(95); } if (t === -2147483528) { parser.report(95); } } if ((t & 20480) === 20480) { parser.report(102); } if (context & (2048 | 2) && t === 209006) { parser.report(110); } if (context & (1024 | 1) && t === 241771) { parser.report(97, 'yield'); } } function isStrictReservedWord(parser, context, t) { if (t === 209006) { if (context & (2048 | 2)) parser.report(110); parser.destructible |= 128; } if (t === 241771 && context & 1024) parser.report(97, 'yield'); return ((t & 20480) === 20480 || (t & 36864) === 36864 || t == -2147483527); } function isPropertyWithPrivateFieldKey(expr) { return !expr.property ? false : expr.property.type === 'PrivateIdentifier'; } function isValidLabel(parser, labels, name, isIterationStatement) { while (labels) { if (labels['$' + name]) { if (isIterationStatement) parser.report(137); return 1; } if (isIterationStatement && labels.loop) isIterationStatement = 0; labels = labels['$']; } return 0; } function validateAndDeclareLabel(parser, labels, name) { let set = labels; while (set) { if (set['$' + name]) parser.report(136, name); set = set['$']; } labels['$' + name] = 1; } function isEqualTagName(elementName) { switch (elementName.type) { case 'JSXIdentifier': return elementName.name; case 'JSXNamespacedName': return elementName.namespace + ':' + elementName.name; case 'JSXMemberExpression': return isEqualTagName(elementName.object) + '.' + isEqualTagName(elementName.property); } } function isValidIdentifier(context, t) { if (context & (1 | 1024)) { if (context & 2 && t === 209006) return false; if (context & 1024 && t === 241771) return false; return (t & 12288) === 12288; } return (t & 12288) === 12288 || (t & 36864) === 36864; } function classifyIdentifier(parser, context, t) { if ((t & 537079808) === 537079808) { if (context & 1) parser.report(119); parser.flags |= 512; } if (!isValidIdentifier(context, t)) parser.report(0); } function getOwnProperty(object, key) { return Object.hasOwn(object, key) ? object[key] : undefined; } function scanIdentifier(parser, context, isValidAsKeyword) { while (isIdPart[advanceChar(parser)]) ; parser.tokenValue = parser.source.slice(parser.tokenIndex, parser.index); return parser.currentChar !== 92 && parser.currentChar <= 0x7e ? (getOwnProperty(descKeywordTable, parser.tokenValue) ?? 208897) : scanIdentifierSlowCase(parser, context, 0, isValidAsKeyword); } function scanUnicodeIdentifier(parser, context) { const cookedChar = scanIdentifierUnicodeEscape(parser); if (!isIdentifierStart(cookedChar)) parser.report(5); parser.tokenValue = String.fromCodePoint(cookedChar); return scanIdentifierSlowCase(parser, context, 1, CharTypes[cookedChar] & 4); } function scanIdentifierSlowCase(parser, context, hasEscape, isValidAsKeyword) { let start = parser.index; while (parser.index < parser.end) { if (parser.currentChar === 92) { parser.tokenValue += parser.source.slice(start, parser.index); hasEscape = 1; const code = scanIdentifierUnicodeEscape(parser); if (!isIdentifierPart(code)) parser.report(5); isValidAsKeyword = isValidAsKeyword && CharTypes[code] & 4; parser.tokenValue += String.fromCodePoint(code); start = parser.index; } else { const merged = consumePossibleSurrogatePair(parser); if (merged > 0) { if (!isIdentifierPart(merged)) { parser.report(20, String.fromCodePoint(merged)); } parser.currentChar = merged; parser.index++; parser.column++; } else if (!isIdentifierPart(parser.currentChar)) { break; } advanceChar(parser); } } if (parser.index <= parser.end) { parser.tokenValue += parser.source.slice(start, parser.index); } const { length } = parser.tokenValue; if (isValidAsKeyword && length >= 2 && length <= 11) { const token = getOwnProperty(descKeywordTable, parser.tokenValue); if (token === void 0) return 208897 | (hasEscape ? -2147483648 : 0); if (!hasEscape) return token; if (token === 209006) { if ((context & (2 | 2048)) === 0) { return token | -2147483648; } return -2147483528; } if (context & 1) { if (token === 36970) { return -2147483527; } if ((token & 36864) === 36864) { return -2147483527; } if ((token & 20480) === 20480) { if (context & 262144 && (context & 8) === 0) { return token | -2147483648; } else { return -2147483528; } } return 209018 | -2147483648; } if (context & 262144 && (context & 8) === 0 && (token & 20480) === 20480) { return token | -2147483648; } if (token === 241771) { return context & 262144 ? 209018 | -2147483648 : context & 1024 ? -2147483528 : token | -2147483648; } if (token === 209005) { return 209018 | -2147483648; } if ((token & 36864) === 36864) { return token | 12288 | -2147483648; } return -2147483528; } return 208897 | (hasEscape ? -2147483648 : 0); } function scanPrivateIdentifier(parser) { let char = advanceChar(parser); if (char === 92) return 130; const merged = consumePossibleSurrogatePair(parser); if (merged) char = merged; if (!isIdentifierStart(char)) parser.report(96); return 130; } function scanIdentifierUnicodeEscape(parser) { if (parser.source.charCodeAt(parser.index + 1) !== 117) { parser.report(5); } parser.currentChar = parser.source.charCodeAt((parser.index += 2)); parser.column += 2; return scanUnicodeEscape(parser); } function scanUnicodeEscape(parser) { let codePoint = 0; const char = parser.currentChar; if (char === 123) { const begin = parser.index - 2; while (CharTypes[advanceChar(parser)] & 64) { codePoint = (codePoint << 4) | toHex(parser.currentChar); if (codePoint > 1114111) throw new ParseError({ index: begin, line: parser.line, column: parser.column }, parser.currentLocation, 104); } if (parser.currentChar !== 125) { throw new ParseError({ index: begin, line: parser.line, column: parser.column }, parser.currentLocation, 7); } advanceChar(parser); return codePoint; } if ((CharTypes[char] & 64) === 0) parser.report(7); const char2 = parser.source.charCodeAt(parser.index + 1); if ((CharTypes[char2] & 64) === 0) parser.report(7); const char3 = parser.source.charCodeAt(parser.index + 2); if ((CharTypes[char3] & 64) === 0) parser.report(7); const char4 = parser.source.charCodeAt(parser.index + 3); if ((CharTypes[char4] & 64) === 0) parser.report(7); codePoint = (toHex(char) << 12) | (toHex(char2) << 8) | (toHex(char3) << 4) | toHex(char4); parser.currentChar = parser.source.charCodeAt((parser.index += 4)); parser.column += 4; return codePoint; } const TokenLookup = [ 128, 128, 128, 128, 128, 128, 128, 128, 128, 127, 135, 127, 127, 129, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 127, 16842798, 134283267, 130, 208897, 8391477, 8390213, 134283267, 67174411, 16, 8391476, 25233968, 18, 25233969, 67108877, 8457014, 134283266, 134283266, 134283266, 134283266, 134283266, 134283266, 134283266, 134283266, 134283266, 134283266, 21, 1074790417, 8456256, 1077936155, 8390721, 22, 132, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 208897, 69271571, 136, 20, 8389959, 208897, 131, 4096, 4096, 4096, 4096, 4096, 4096, 4096, 208897, 4096, 208897, 208897, 4096, 208897, 4096, 208897, 4096, 208897, 4096, 4096, 4096, 208897, 4096, 4096, 208897, 4096, 4096, 2162700, 8389702, 1074790415, 16842799, 128, ]; function nextToken(parser, context) { parser.flags = (parser.flags | 1) ^ 1; parser.startIndex = parser.index; parser.startColumn = parser.column; parser.startLine = parser.line; parser.setToken(scanSingleToken(parser, context, 0)); } function scanSingleToken(parser, context, state) { const isStartOfLine = parser.index === 0; const { source } = parser; let start = parser.currentLocation; while (parser.index < parser.end) { parser.tokenIndex = parser.index; parser.tokenColumn = parser.column; parser.tokenLine = parser.line; let char = parser.currentChar; if (char <= 0x7e) { const token = TokenLookup[char]; switch (token) { case 67174411: case 16: case 2162700: case 1074790415: case 69271571: case 20: case 21: case 1074790417: case 18: case 16842799: case 132: case 128: advanceChar(parser); return token; case 208897: return scanIdentifier(parser, context, 0); case 4096: return scanIdentifier(parser, context, 1); case 134283266: return scanNumber(parser, context, 16 | 128); case 134283267: return scanString(parser, context, char); case 131: return scanTemplate(parser, context); case 136: return scanUnicodeIdentifier(parser, context); case 130: return scanPrivateIdentifier(parser); case 127: advanceChar(parser); break; case 129: state |= 1 | 4; scanNewLine(parser); break; case 135: consumeLineFeed(parser, state); state = (state & -5) | 1; break; case 8456256: { const ch = advanceChar(parser); if (parser.index < parser.end) { if (ch === 60) { if (parser.index < parser.end && advanceChar(parser) === 61) { advanceChar(parser); return 4194332; } return 8390978; } else if (ch === 61) { advanceChar(parser); return 8390718; } if (ch === 33) { const index = parser.index + 1; if (index + 1 < parser.end && source.charCodeAt(index) === 45 && source.charCodeAt(index + 1) == 45) { parser.column += 3; parser.currentChar = source.charCodeAt((parser.index += 3)); state = skipSingleHTMLComment(parser, source, state, context, 2, parser.tokenStart); start = parser.tokenStart; continue; } return 8456256; } } return 8456256; } case 1077936155: { advanceChar(parser); const ch = parser.currentChar; if (ch === 61) { if (advanceChar(parser) === 61) { advanceChar(parser); return 8390458; } return 8390460; } if (ch === 62) { advanceChar(parser); return 10; } return 1077936155; } case 16842798: if (advanceChar(parser) !== 61) { return 16842798; } if (advanceChar(parser) !== 61) { return 8390461; } advanceChar(parser); return 8390459; case 8391477: if (advanceChar(parser) !== 61) return 8391477; advanceChar(parser); return 4194340; case 8391476: { advanceChar(parser); if (parser.index >= parser.end) return 8391476; const ch = parser.currentChar; if (ch === 61) { advanceChar(parser); return 4194338; } if (ch !== 42) return 8391476; if (advanceChar(parser) !== 61) return 8391735; advanceChar(parser); return 4194335; } case 8389959: if (advanceChar(parser) !== 61) return 8389959; advanceChar(parser); return 4194341; case 25233968: { advanceChar(parser); const ch = parser.currentChar; if (ch === 43) { advanceChar(parser); return 33619993; } if (ch === 61) { advanceChar(parser); return 4194336; } return 25233968; } case 25233969: { advanceChar(parser); const ch = parser.currentChar; if (ch === 45) { advanceChar(parser); if ((state & 1 || isStartOfLine) && parser.currentChar === 62) { if (!parser.options.webcompat) parser.report(112); advanceChar(parser); state = skipSingleHTMLComment(parser, source, state, context, 3, start); start = parser.tokenStart; continue; } return 33619994; } if (ch === 61) { advanceChar(parser); return 4194337; } return 25233969; } case 8457014: { advanceChar(parser); if (parser.index < parser.end) { const ch = parser.currentChar; if (ch === 47) { advanceChar(parser); state = skipSingleLineComment(parser, source, state, 0, parser.tokenStart); start = parser.tokenStart; continue; } if (ch === 42) { advanceChar(parser); state = skipMultiLineComment(parser, source, state); start = parser.tokenStart; continue; } if (context & 32) { return scanRegularExpression(parser); } if (ch === 61) { advanceChar(parser); return 4259875; } } return 8457014; } case 67108877: { const next = advanceChar(parser); if (next >= 48 && next <= 57) return scanNumber(parser, context, 64 | 16); if (next === 46) { const index = parser.index + 1; if (index < parser.end && source.charCodeAt(index) === 46) { parser.column += 2; parser.currentChar = source.charCodeAt((parser.index += 2)); return 14; } } return 67108877; } case 8389702: { advanceChar(parser); const ch = parser.currentChar; if (ch === 124) { advanceChar(parser); if (parser.currentChar === 61) { advanceChar(parser); return 4194344; } return 8913465; } if (ch === 61) { advanceChar(parser); return 4194342; } return 8389702; } case 8390721: { advanceChar(parser); const ch = parser.currentChar; if (ch === 61) { advanceChar(parser); return 8390719; } if (ch !== 62) return 8390721; advanceChar(parser); if (parser.index < parser.end) { const ch = parser.currentChar; if (ch === 62) { if (advanceChar(parser) === 61) { advanceChar(parser); return 4194334; } return 8390980; } if (ch === 61) { advanceChar(parser); return 4194333; } } return 8390979; } case 8390213: { advanceChar(parser); const ch = parser.currentChar; if (ch === 38) { advanceChar(parser); if (parser.currentChar === 61) { advanceChar(parser); return 4194345; } return 8913720; } if (ch === 61) { advanceChar(parser); return 4194343; } return 8390213; } case 22: { let ch = advanceChar(parser); if (ch === 63) { advanceChar(parser); if (parser.currentChar === 61) { advanceChar(parser); return 4194346; } return 276824445; } if (ch === 46) { const index = parser.index + 1; if (index < parser.end) { ch = source.charCodeAt(index); if (!(ch >= 48 && ch <= 57)) { advanceChar(parser); return 67108990; } } } return 22; } } } else { if ((char ^ 8232) <= 1) { state = (state & -5) | 1; scanNewLine(parser); continue; } const merged = consumePossibleSurrogatePair(parser); if (merged > 0) char = merged; if (isIDStart(char)) { parser.tokenValue = ''; return scanIdentifierSlowCase(parser, context, 0, 0); } if (isExoticECMAScriptWhitespace(char)) { advanceChar(parser); continue; } parser.report(20, String.fromCodePoint(char)); } } return 1048576; } const entities = { AElig: '\u00C6', AMP: '\u0026', Aacute: '\u00C1', Abreve: '\u0102', Acirc: '\u00C2', Acy: '\u0410', Afr: '\uD835\uDD04', Agrave: '\u00C0', Alpha: '\u0391', Amacr: '\u0100', And: '\u2A53', Aogon: '\u0104', Aopf: '\uD835\uDD38', ApplyFunction: '\u2061', Aring: '\u00C5', Ascr: '\uD835\uDC9C', Assign: '\u2254', Atilde: '\u00C3', Auml: '\u00C4', Backslash: '\u2216', Barv: '\u2AE7', Barwed: '\u2306', Bcy: '\u0411', Because: '\u2235', Bernoullis: '\u212C', Beta: '\u0392', Bfr: '\uD835\uDD05', Bopf: '\uD835\uDD39', Breve: '\u02D8', Bscr: '\u212C', Bumpeq: '\u224E', CHcy: '\u0427', COPY: '\u00A9', Cacute: '\u0106', Cap: '\u22D2', CapitalDifferentialD: '\u2145', Cayleys: '\u212D', Ccaron: '\u010C', Ccedil: '\u00C7', Ccirc: '\u0108', Cconint: '\u2230', Cdot: '\u010A', Cedilla: '\u00B8', CenterDot: '\u00B7', Cfr: '\u212D', Chi: '\u03A7', CircleDot: '\u2299', CircleMinus: '\u2296', CirclePlus: '\u2295', CircleTimes: '\u2297', ClockwiseContourIntegral: '\u2232', CloseCurlyDoubleQuote: '\u201D', CloseCurlyQuote: '\u2019', Colon: '\u2237', Colone: '\u2A74', Congruent: '\u2261', Conint: '\u222F', ContourIntegral: '\u222E', Copf: '\u2102', Coproduct: '\u2210', CounterClockwiseContourIntegral: '\u2233', Cross: '\u2A2F', Cscr: '\uD835\uDC9E', Cup: '\u22D3', CupCap: '\u224D', DD: '\u2145', DDotrahd: '\u2911', DJcy: '\u0402', DScy: '\u0405', DZcy: '\u040F', Dagger: '\u2021', Darr: '\u21A1', Dashv: '\u2AE4', Dcaron: '\u010E', Dcy: '\u0414', Del: '\u2207', Delta: '\u0394', Dfr: '\uD835\uDD07', DiacriticalAcute: '\u00B4', DiacriticalDot: '\u02D9', DiacriticalDoubleAcute: '\u02DD', DiacriticalGrave: '\u0060', DiacriticalTilde: '\u02DC', Diamond: '\u22C4', DifferentialD: '\u2146', Dopf: '\uD835\uDD3B', Dot: '\u00A8', DotDot: '\u20DC', DotEqual: '\u2250', DoubleContourIntegral: '\u222F', DoubleDot: '\u00A8', DoubleDownArrow: '\u21D3', DoubleLeftArrow: '\u21D0', DoubleLeftRightArrow: '\u21D4', DoubleLeftTee: '\u2AE4', DoubleLongLeftArrow: '\u27F8', DoubleLongLeftRightArrow: '\u27FA', DoubleLongRightArrow: '\u27F9', DoubleRightArrow: '\u21D2', DoubleRightTee: '\u22A8', DoubleUpArrow: '\u21D1', DoubleUpDownArrow: '\u21D5', DoubleVerticalBar: '\u2225', DownArrow: '\u2193', DownArrowBar: '\u2913', DownArrowUpArrow: '\u21F5', DownBreve: '\u0311', DownLeftRightVector: '\u2950', DownLeftTeeVector: '\u295E', DownLeftVector: '\u21BD', DownLeftVectorBar: '\u2956', DownRightTeeVector: '\u295F', DownRightVector: '\u21C1', DownRightVectorBar: '\u2957', DownTee: '\u22A4', DownTeeArrow: '\u21A7', Downarrow: '\u21D3', Dscr: '\uD835\uDC9F', Dstrok: '\u0110', ENG: '\u014A', ETH: '\u00D0', Eacute: '\u00C9', Ecaron: '\u011A', Ecirc: '\u00CA', Ecy: '\u042D', Edot: '\u0116', Efr: '\uD835\uDD08', Egrave: '\u00C8', Element: '\u2208', Emacr: '\u0112', EmptySmallSquare: '\u25FB', EmptyVerySmallSquare: '\u25AB', Eogon: '\u0118', Eopf: '\uD835\uDD3C', Epsilon: '\u0395', Equal: '\u2A75', EqualTilde: '\u2242', Equilibrium: '\u21CC', Escr: '\u2130', Esim: '\u2A73', Eta: '\u0397', Euml: '\u00CB', Exists: '\u2203', ExponentialE: '\u2147', Fcy: '\u0424', Ffr: '\uD835\uDD09', FilledSmallSquare: '\u25FC', FilledVerySmallSquare: '\u25AA', Fopf: '\uD835\uDD3D', ForAll: '\u2200', Fouriertrf: '\u2131', Fscr: '\u2131', GJcy: '\u0403', GT: '\u003E', Gamma: '\u0393', Gammad: '\u03DC', Gbreve: '\u011E', Gcedil: '\u0122', Gcirc: '\u011C', Gcy: '\u0413', Gdot: '\u0120', Gfr: '\uD835\uDD0A', Gg: '\u22D9', Gopf: '\uD835\uDD3E', GreaterEqual: '\u2265', GreaterEqualLess: '\u22DB', GreaterFullEqual: '\u2267', GreaterGreater: '\u2AA2', GreaterLess: '\u2277', GreaterSlantEqual: '\u2A7E', GreaterTilde: '\u2273', Gscr: '\uD835\uDCA2', Gt: '\u226B', HARDcy: '\u042A', Hacek: '\u02C7', Hat: '\u005E', Hcirc: '\u0124', Hfr: '\u210C', HilbertSpace: '\u210B', Hopf: '\u210D', HorizontalLine: '\u2500', Hscr: '\u210B', Hstrok: '\u0126', HumpDownHump: '\u224E', HumpEqual: '\u224F', IEcy: '\u0415', IJlig: '\u0132', IOcy: '\u0401', Iacute: '\u00CD', Icirc: '\u00CE', Icy: '\u0418', Idot: '\u0130', Ifr: '\u2111', Igrave: '\u00CC', Im: '\u2111', Imacr: '\u012A', ImaginaryI: '\u2148', Implies: '\u21D2', Int: '\u222C', Integral: '\u222B', Intersection: '\u22C2', InvisibleComma: '\u2063', InvisibleTimes: '\u2062', Iogon: '\u012E', Iopf: '\uD835\uDD40', Iota: '\u0399', Iscr: '\u2110', Itilde: '\u0128', Iukcy: '\u0406', Iuml: '\u00CF', Jcirc: '\u0134', Jcy: '\u0419', Jfr: '\uD835\uDD0D', Jopf: '\uD835\uDD41', Jscr: '\uD835\uDCA5', Jsercy: '\u0408', Jukcy: '\u0404', KHcy: '\u0425', KJcy: '\u040C', Kappa: '\u039A', Kcedil: '\u0136', Kcy: '\u041A', Kfr: '\uD835\uDD0E', Kopf: '\uD835\uDD42', Kscr: '\uD835\uDCA6', LJcy: '\u0409', LT: '\u003C', Lacute: '\u0139', Lambda: '\u039B', Lang: '\u27EA', Laplacetrf: '\u2112', Larr: '\u219E', Lcaron: '\u013D', Lcedil: '\u013B', Lcy: '\u041B', LeftAngleBracket: '\u27E8', LeftArrow: '\u2190', LeftArrowBar: '\u21E4', LeftArrowRightArrow: '\u21C6', LeftCeiling: '\u2308', LeftDoubleBracket: '\u27E6', LeftDownTeeVector: '\u2961', LeftDownVector: '\u21C3', LeftDownVectorBar: '\u2959', LeftFloor: '\u230A', LeftRightArrow: '\u2194', LeftRightVector: '\u294E', LeftTee: '\u22A3', LeftTeeArrow: '\u21A4', LeftTeeVector: '\u295A', LeftTriangle: '\u22B2', LeftTriangleBar: '\u29CF', LeftTriangleEqual: '\u22B4', LeftUpDownVector: '\u2951', LeftUpTeeVector: '\u2960', LeftUpVector: '\u21BF', LeftUpVectorBar: '\u2958', LeftVector: '\u21BC', LeftVectorBar: '\u2952', Leftarrow: '\u21D0', Leftrightarrow: '\u21D4', LessEqualGreater: '\u22DA', LessFullEqual: '\u2266', LessGreater: '\u2276', LessLess: '\u2AA1', LessSlantEqual: '\u2A7D', LessTilde: '\u2272', Lfr: '\uD835\uDD0F', Ll: '\u22D8', Lleftarrow: '\u21DA', Lmidot: '\u013F', LongLeftArrow: '\u27F5', LongLeftRightArrow: '\u27F7', LongRightArrow: '\u27F6', Longleftarrow: '\u27F8', Longleftrightarrow: '\u27FA', Longrightarrow: '\u27F9', Lopf: '\uD835\uDD43', LowerLeftArrow: '\u2199', LowerRightArrow: '\u2198', Lscr: '\u2112', Lsh: '\u21B0', Lstrok: '\u0141', Lt: '\u226A', Map: '\u2905', Mcy: '\u041C', MediumSpace: '\u205F', Mellintrf: '\u2133', Mfr: '\uD835\uDD10', MinusPlus: '\u2213', Mopf: '\uD835\uDD44', Mscr: '\u2133', Mu: '\u039C', NJcy: '\u040A', Nacute: '\u0143', Ncaron: '\u0147', Ncedil: '\u0145', Ncy: '\u041D', NegativeMediumSpace: '\u200B', NegativeThickSpace: '\u200B', NegativeThinSpace: '\u200B', NegativeVeryThinSpace: '\u200B', NestedGreaterGreater: '\u226B', NestedLessLess: '\u226A', NewLine: '\u000A', Nfr: '\uD835\uDD11', NoBreak: '\u2060', NonBreakingSpace: '\u00A0', Nopf: '\u2115', Not: '\u2AEC', NotCongruent: '\u2262', NotCupCap: '\u226D', NotDoubleVerticalBar: '\u2226', NotElement: '\u2209', NotEqual: '\u2260', NotEqualTilde: '\u2242\u0338', NotExists: '\u2204', NotGreater: '\u226F', NotGreaterEqual: '\u2271', NotGreaterFullEqual: '\u2267\u0338', NotGreaterGreater: '\u226B\u0338', NotGreaterLess: '\u2279', NotGreaterSlantEqual: '\u2A7E\u0338', NotGreaterTilde: '\u2275', NotHumpDownHump: '\u224E\u0338', NotHumpEqual: '\u224F\u0338', NotLeftTriangle: '\u22EA', NotLeftTriangleBar: '\u29CF\u0338', NotLeftTriangleEqual: '\u22EC', NotLess: '\u226E', NotLessEqual: '\u2270', NotLessGreater: '\u2278', NotLessLess: '\u226A\u0338', NotLessSlantEqual: '\u2A7D\u0338', NotLessTilde: '\u2274', NotNestedGreaterGreater: '\u2AA2\u0338', NotNestedLessLess: '\u2AA1\u0338', NotPrecedes: '\u2280', NotPrecedesEqual: '\u2AAF\u0338', NotPrecedesSlantEqual: '\u22E0', NotReverseElement: '\u220C', NotRightTriangle: '\u22EB', NotRightTriangleBar: '\u29D0\u0338', NotRightTriangleEqual: '\u22ED', NotSquareSubset: '\u228F\u0338', NotSquareSubsetEqual: '\u22E2', NotSquareSuperset: '\u2290\u0338', NotSquareSupersetEqual: '\u22E3', NotSubset: '\u2282\u20D2', NotSubsetEqual: '\u2288', NotSucceeds: '\u2281', NotSucceedsEqual: '\u2AB0\u0338', NotSucceedsSlantEqual: '\u22E1', NotSucceedsTilde: '\u227F\u0338', NotSuperset: '\u2283\u20D2', NotSupersetEqual: '\u2289', NotTilde: '\u2241', NotTildeEqual: '\u2244', NotTildeFullEqual: '\u2247', NotTildeTilde: '\u2249', NotVerticalBar: '\u2224', Nscr: '\uD835\uDCA9', Ntilde: '\u00D1', Nu: '\u039D', OElig: '\u0152', Oacute: '\u00D3', Ocirc: '\u00D4', Ocy: '\u041E', Odblac: '\u0150', Ofr: '\uD835\uDD12', Ograve: '\u00D2', Omacr: '\u014C', Omega: '\u03A9', Omicron: '\u039F', Oopf: '\uD835\uDD46', OpenCurlyDoubleQuote: '\u201C', OpenCurlyQuote: '\u2018', Or: '\u2A54', Oscr: '\uD835\uDCAA', Oslash: '\u00D8', Otilde: '\u00D5', Otimes: '\u2A37', Ouml: '\u00D6', OverBar: '\u203E', OverBrace: '\u23DE', OverBracket: '\u23B4', OverParenthesis: '\u23DC', PartialD: '\u2202', Pcy: '\u041F', Pfr: '\uD835\uDD13', Phi: '\u03A6', Pi: '\u03A0', PlusMinus: '\u00B1', Poincareplane: '\u210C', Popf: '\u2119', Pr: '\u2ABB', Precedes: '\u227A', PrecedesEqual: '\u2AAF', PrecedesSlantEqual: '\u227C', PrecedesTilde: '\u227E', Prime: '\u2033', Product: '\u220F', Proportion: '\u2237', Proportional: '\u221D', Pscr: '\uD835\uDCAB', Psi: '\u03A8', QUOT: '\u0022', Qfr: '\uD835\uDD14', Qopf: '\u211A', Qscr: '\uD835\uDCAC', RBarr: '\u2910', REG: '\u00AE', Racute: '\u0154', Rang: '\u27EB', Rarr: '\u21A0', Rarrtl: '\u2916', Rcaron: '\u0158', Rcedil: '\u0156', Rcy: '\u0420', Re: '\u211C', ReverseElement: '\u220B', ReverseEquilibrium: '\u21CB', ReverseUpEquilibrium: '\u296F', Rfr: '\u211C', Rho: '\u03A1', RightAngleBracket: '\u27E9', RightArrow: '\u2192', RightArrowBar: '\u21E5', RightArrowLeftArrow: '\u21C4', RightCeiling: '\u2309', RightDoubleBracket: '\u27E7', RightDownTeeVector: '\u295D', RightDownVector: '\u21C2', RightDownVectorBar: '\u2955', RightFloor: '\u230B', RightTee: '\u22A2', RightTeeArrow: '\u21A6', RightTeeVector: '\u295B', RightTriangle: '\u22B3', RightTriangleBar: '\u29D0', RightTriangleEqual: '\u22B5', RightUpDownVector: '\u294F', RightUpTeeVector: '\u295C', RightUpVector: '\u21BE', RightUpVectorBar: '\u2954', RightVector: '\u21C0', RightVectorBar: '\u2953', Rightarrow: '\u21D2', Ropf: '\u211D', RoundImplies: '\u2970', Rrightarrow: '\u21DB', Rscr: '\u211B', Rsh: '\u21B1', RuleDelayed: '\u29F4', SHCHcy: '\u0429', SHcy: '\u0428', SOFTcy: '\u042C', Sacute: '\u015A', Sc: '\u2ABC', Scaron: '\u0160', Scedil: '\u015E', Scirc: '\u015C', Scy: '\u0421', Sfr: '\uD835\uDD16', ShortDownArrow: '\u2193', ShortLeftArrow: '\u2190', ShortRightArrow: '\u2192', ShortUpArrow: '\u2191', Sigma: '\u03A3', SmallCircle: '\u2218', Sopf: '\uD835\uDD4A', Sqrt: '\u221A', Square: '\u25A1', SquareIntersection: '\u2293', SquareSubset: '\u228F', SquareSubsetEqual: '\u2291', SquareSuperset: '\u2290', SquareSupersetEqual: '\u2292', SquareUnion: '\u2294', Sscr: '\uD835\uDCAE', Star: '\u22C6', Sub: '\u22D0', Subset: '\u22D0', SubsetEqual: '\u2286', Succeeds: '\u227B', SucceedsEqual: '\u2AB0', SucceedsSlantEqual: '\u227D', SucceedsTilde: '\u227F', SuchThat: '\u220B', Sum: '\u2211', Sup: '\u22D1', Superset: '\u2283', SupersetEqual: '\u2287', Supset: '\u22D1', THORN: '\u00DE', TRADE: '\u2122', TSHcy: '\u040B', TScy: '\u0426', Tab: '\u0009', Tau: '\u03A4', Tcaron: '\u0164', Tcedil: '\u0162', Tcy: '\u0422', Tfr: '\uD835\uDD17', Therefore: '\u2234', Theta: '\u0398', ThickSpace: '\u205F\u200A', ThinSpace: '\u2009', Tilde: '\u223C', TildeEqual: '\u2243', TildeFullEqual: '\u2245', TildeTilde: '\u2248', Topf: '\uD835\uDD4B', TripleDot: '\u20DB', Tscr: '\uD835\uDCAF', Tstrok: '\u0166', Uacute: '\u00DA', Uarr: '\u219F', Uarrocir: '\u2949', Ubrcy: '\u040E', Ubreve: '\u016C', Ucirc: '\u00DB', Ucy: '\u0423', Udblac: '\u0170', Ufr: '\uD835\uDD18', Ugrave: '\u00D9', Umacr: '\u016A', UnderBar: '\u005F', UnderBrace: '\u23DF', UnderBracket: '\u23B5', UnderParenthesis: '\u23DD', Union: '\u22C3', UnionPlus: '\u228E', Uogon: '\u0172', Uopf: '\uD835\uDD4C', UpArrow: '\u2191', UpArrowBar: '\u2912', UpArrowDownArrow: '\u21C5', UpDownArrow: '\u2195', UpEquilibrium: '\u296E', UpTee: '\u22A5', UpTeeArrow: '\u21A5', Uparrow: '\u21D1', Updownarrow: '\u21D5', UpperLeftArrow: '\u2196', UpperRightArrow: '\u2197', Upsi: '\u03D2', Upsilon: '\u03A5', Uring: '\u016E', Uscr: '\uD835\uDCB0', Utilde: '\u0168', Uuml: '\u00DC', VDash: '\u22AB', Vbar: '\u2AEB', Vcy: '\u0412', Vdash: '\u22A9', Vdashl: '\u2AE6', Vee: '\u22C1', Verbar: '\u2016', Vert: '\u2016', VerticalBar: '\u2223', VerticalLine: '\u007C', VerticalSeparator: '\u2758', VerticalTilde: '\u2240', VeryThinSpace: '\u200A', Vfr: '\uD835\uDD19', Vopf: '\uD835\uDD4D', Vscr: '\uD835\uDCB1', Vvdash: '\u22AA', Wcirc: '\u0174', Wedge: '\u22C0', Wfr: '\uD835\uDD1A', Wopf: '\uD835\uDD4E', Wscr: '\uD835\uDCB2', Xfr: '\uD835\uDD1B', Xi: '\u039E', Xopf: '\uD835\uDD4F', Xscr: '\uD835\uDCB3', YAcy: '\u042F', YIcy: '\u0407', YUcy: '\u042E', Yacute: '\u00DD', Ycirc: '\u0176', Ycy: '\u042B', Yfr: '\uD835\uDD1C', Yopf: '\uD835\uDD50', Yscr: '\uD835\uDCB4', Yuml: '\u0178', ZHcy: '\u0416', Zacute: '\u0179', Zcaron: '\u017D', Zcy: '\u0417', Zdot: '\u017B', ZeroWidthSpace: '\u200B', Zeta: '\u0396', Zfr: '\u2128', Zopf: '\u2124', Zscr: '\uD835\uDCB5', aacute: '\u00E1', abreve: '\u0103', ac: '\u223E', acE: '\u223E\u0333', acd: '\u223F', acirc: '\u00E2', acute: '\u00B4', acy: '\u0430', aelig: '\u00E6', af: '\u2061', afr: '\uD835\uDD1E', agrave: '\u00E0', alefsym: '\u2135', aleph: '\u2135', alpha: '\u03B1', amacr: '\u0101', amalg: '\u2A3F', amp: '\u0026', and: '\u2227', andand: '\u2A55', andd: '\u2A5C', andslope: '\u2A58', andv: '\u2A5A', ang: '\u2220', ange: '\u29A4', angle: '\u2220', angmsd: '\u2221', angmsdaa: '\u29A8', angmsdab: '\u29A9', angmsdac: '\u29AA', angmsdad: '\u29AB', angmsdae: '\u29AC', angmsdaf: '\u29AD', angmsdag: '\u29AE', angmsdah: '\u29AF', angrt: '\u221F', angrtvb: '\u22BE', angrtvbd: '\u299D', angsph: '\u2222', angst: '\u00C5', angzarr: '\u237C', aogon: '\u0105', aopf: '\uD835\uDD52', ap: '\u2248', apE: '\u2A70', apacir: '\u2A6F', ape: '\u224A', apid: '\u224B', apos: '\u0027', approx: '\u2248', approxeq: '\u224A', aring: '\u00E5', ascr: '\uD835\uDCB6', ast: '\u002A', asymp: '\u2248', asympeq: '\u224D', atilde: '\u00E3', auml: '\u00E4', awconint: '\u2233', awint: '\u2A11', bNot: '\u2AED', backcong: '\u224C', backepsilon: '\u03F6', backprime: '\u2035', backsim: '\u223D', backsimeq: '\u22CD', barvee: '\u22BD', barwed: '\u2305', barwedge: '\u2305', bbrk: '\u23B5', bbrktbrk: '\u23B6', bcong: '\u224C', bcy: '\u0431', bdquo: '\u201E', becaus: '\u2235', because: '\u2235', bemptyv: '\u29B0', bepsi: '\u03F6', bernou: '\u212C', beta: '\u03B2', beth: '\u2136', between: '\u226C', bfr: '\uD835\uDD1F', bigcap: '\u22C2', bigcirc: '\u25EF', bigcup: '\u22C3', bigodot: '\u2A00', bigoplus: '\u2A01', bigotimes: '\u2A02', bigsqcup: '\u2A06', bigstar: '\u2605', bigtriangledown: '\u25BD', bigtriangleup: '\u25B3', biguplus: '\u2A04', bigvee: '\u22C1', bigwedge: '\u22C0', bkarow: '\u290D', blacklozenge: '\u29EB', blacksquare: '\u25AA', blacktriangle: '\u25B4', blacktriangledown: '\u25BE', blacktriangleleft: '\u25C2', blacktriangleright: '\u25B8', blank: '\u2423', blk12: '\u2592', blk14: '\u2591', blk34: '\u2593', block: '\u2588', bne: '\u003D\u20E5', bnequiv: '\u2261\u20E5', bnot: '\u2310', bopf: '\uD835\uDD53', bot: '\u22A5', bottom: '\u22A5', bowtie: '\u22C8', boxDL: '\u2557', boxDR: '\u2554', boxDl: '\u2556', boxDr: '\u2553', boxH: '\u2550', boxHD: '\u2566', boxHU: '\u2569', boxHd: '\u2564', boxHu: '\u2567', boxUL: '\u255D', boxUR: '\u255A', boxUl: '\u255C', boxUr: '\u2559', boxV: '\u2551', boxVH: '\u256C', boxVL: '\u2563', boxVR: '\u2560', boxVh: '\u256B', boxVl: '\u2562', boxVr: '\u255F', boxbox: '\u29C9', boxdL: '\u2555', boxdR: '\u2552', boxdl: '\u2510', boxdr: '\u250C', boxh: '\u2500', boxhD: '\u2565', boxhU: '\u2568', boxhd: '\u252C', boxhu: '\u2534', boxminus: '\u229F', boxplus: '\u229E', boxtimes: '\u22A0', boxuL: '\u255B', boxuR: '\u2558', boxul: '\u2518', boxur: '\u2514', boxv: '\u2502', boxvH: '\u256A', boxvL: '\u2561', boxvR: '\u255E', boxvh: '\u253C', boxvl: '\u2524', boxvr: '\u251C', bprime: '\u2035', breve: '\u02D8', brvbar: '\u00A6', bscr: '\uD835\uDCB7', bsemi: '\u204F', bsim: '\u223D', bsime: '\u22CD', bsol: '\u005C', bsolb: '\u29C5', bsolhsub: '\u27C8', bull: '\u2022', bullet: '\u2022', bump: '\u224E', bumpE: '\u2AAE', bumpe: '\u224F', bumpeq: '\u224F', cacute: '\u0107', cap: '\u2229', capand: '\u2A44', capbrcup: '\u2A49', capcap: '\u2A4B', capcup: '\u2A47', capdot: '\u2A40', caps: '\u2229\uFE00', caret: '\u2041', caron: '\u02C7', ccaps: '\u2A4D', ccaron: '\u010D', ccedil: '\u00E7', ccirc: '\u0109', ccups: '\u2A4C', ccupssm: '\u2A50', cdot: '\u010B', cedil: '\u00B8', cemptyv: '\u29B2', cent: '\u00A2', centerdot: '\u00B7', cfr: '\uD835\uDD20', chcy: '\u0447', check: '\u2713', checkmark: '\u2713', chi: '\u03C7', cir: '\u25CB', cirE: '\u29C3', circ: '\u02C6', circeq: '\u2257', circlearrowleft: '\u21BA', circlearrowright: '\u21BB', circledR: '\u00AE', circledS: '\u24C8', circledast: '\u229B', circledcirc: '\u229A', circleddash: '\u229D', cire: '\u2257', cirfnint: '\u2A10', cirmid: '\u2AEF', cirscir: '\u29C2', clubs: '\u2663', clubsuit: '\u2663', colon: '\u003A', colone: '\u2254', coloneq: '\u2254', comma: '\u002C', commat: '\u0040', comp: '\u2201', compfn: '\u2218', complement: '\u2201', complexes: '\u2102', cong: '\u2245', congdot: '\u2A6D', conint: '\u222E', copf: '\uD835\uDD54', coprod: '\u2210', copy: '\u00A9', copysr: '\u2117', crarr: '\u21B5', cross: '\u2717', cscr: '\uD835\uDCB8', csub: '\u2ACF', csube: '\u2AD1', csup: '\u2AD0', csupe: '\u2AD2', ctdot: '\u22EF', cudarrl: '\u2938', cudarrr: '\u2935', cuepr: '\u22DE', cuesc: '\u22DF', cularr: '\u21B6', cularrp: '\u293D', cup: '\u222A', cupbrcap: '\u2A48', cupcap: '\u2A46', cupcup: '\u2A4A', cupdot: '\u228D', cupor: '\u2A45', cups: '\u222A\uFE00', curarr: '\u21B7', curarrm: '\u293C', curlyeqprec: '\u22DE', curlyeqsucc: '\u22DF', curlyvee: '\u22CE', curlywedge: '\u22CF', curren: '\u00A4', curvearrowleft: '\u21B6', curvearrowright: '\u21B7', cuvee: '\u22CE', cuwed: '\u22CF', cwconint: '\u2232', cwint: '\u2231', cylcty: '\u232D', dArr: '\u21D3', dHar: '\u2965', dagger: '\u2020', daleth: '\u2138', darr: '\u2193', dash: '\u2010', dashv: '\u22A3', dbkarow: '\u290F', dblac: '\u02DD', dcaron: '\u010F', dcy: '\u0434', dd: '\u2146', ddagger: '\u2021', ddarr: '\u21CA', ddotseq: '\u2A77', deg: '\u00B0', delta: '\u03B4', demptyv: '\u29B1', dfisht: '\u297F', dfr: '\uD835\uDD21', dharl: '\u21C3', dharr: '\u21C2', diam: '\u22C4', diamond: '\u22C4', diamondsuit: '\u2666', diams: '\u2666', die: '\u00A8', digamma: '\u03DD', disin: '\u22F2', div: '\u00F7', divide: '\u00F7', divideontimes: '\u22C7', divonx: '\u22C7', djcy: '\u0452', dlcorn: '\u231E', dlcrop: '\u230D', dollar: '\u0024', dopf: '\uD835\uDD55', dot: '\u02D9', doteq: '\u2250', doteqdot: '\u2251', dotminus: '\u2238', dotplus: '\u2214', dotsquare: '\u22A1', doublebarwedge: '\u2306', downarrow: '\u2193', downdownarrows: '\u21CA', downharpoonleft: '\u21C3', downharpoonright: '\u21C2', drbkarow: '\u2910', drcorn: '\u231F', drcrop: '\u230C', dscr: '\uD835\uDCB9', dscy: '\u0455', dsol: '\u29F6', dstrok: '\u0111', dtdot: '\u22F1', dtri: '\u25BF', dtrif: '\u25BE', duarr: '\u21F5', duhar: '\u296F', dwangle: '\u29A6', dzcy: '\u045F', dzigrarr: '\u27FF', eDDot: '\u2A77', eDot: '\u2251', eacute: '\u00E9', easter: '\u2A6E', ecaron: '\u011B', ecir: '\u2256', ecirc: '\u00EA', ecolon: '\u2255', ecy: '\u044D', edot: '\u0117', ee: '\u2147', efDot: '\u2252', efr: '\uD835\uDD22', eg: '\u2A9A', egrave: '\u00E8', egs: '\u2A96', egsdot: '\u2A98', el: '\u2A99', elinters: '\u23E7', ell: '\u2113', els: '\u2A95', elsdot: '\u2A97', emacr: '\u0113', empty: '\u2205', emptyset: '\u2205', emptyv: '\u2205', emsp13: '\u2004', emsp14: '\u2005', emsp: '\u2003', eng: '\u014B', ensp: '\u2002', eogon: '\u0119', eopf: '\uD835\uDD56', epar: '\u22D5', eparsl: '\u29E3', eplus: '\u2A71', epsi: '\u03B5', epsilon: '\u03B5', epsiv: '\u03F5', eqcirc: '\u2256', eqcolon: '\u2255', eqsim: '\u2242', eqslantgtr: '\u2A96', eqslantless: '\u2A95', equals: '\u003D', equest: '\u225F', equiv: '\u2261', equivDD: '\u2A78', eqvparsl: '\u29E5', erDot: '\u2253', erarr: '\u2971', escr: '\u212F', esdot: '\u2250', esim: '\u2242', eta: '\u03B7', eth: '\u00F0', euml: '\u00EB', euro: '\u20AC', excl: '\u0021', exist: '\u2203', expectation: '\u2130', exponentiale: '\u2147', fallingdotseq: '\u2252', fcy: '\u0444', female: '\u2640', ffilig: '\uFB03', fflig: '\uFB00', ffllig: '\uFB04', ffr: '\uD835\uDD23', filig: '\uFB01', fjlig: '\u0066\u006A', flat: '\u266D', fllig: '\uFB02', fltns: '\u25B1', fnof: '\u0192', fopf: '\uD835\uDD57', forall: '\u2200', fork: '\u22D4', forkv: '\u2AD9', fpartint: '\u2A0D', frac12: '\u00BD', frac13: '\u2153', frac14: '\u00BC', frac15: '\u2155', frac16: '\u2159', frac18: '\u215B', frac23: '\u2154', frac25: '\u2156', frac34: '\u00BE', frac35: '\u2157', frac38: '\u215C', frac45: '\u2158', frac56: '\u215A', frac58: '\u215D', frac78: '\u215E', frasl: '\u2044', frown: '\u2322', fscr: '\uD835\uDCBB', gE: '\u2267', gEl: '\u2A8C', gacute: '\u01F5', gamma: '\u03B3', gammad: '\u03DD', gap: '\u2A86', gbreve: '\u011F', gcirc: '\u011D', gcy: '\u0433', gdot: '\u0121', ge: '\u2265', gel: '\u22DB', geq: '\u2265', geqq: '\u2267', geqslant: '\u2A7E', ges: '\u2A7E', gescc: '\u2AA9', gesdot: '\u2A80', gesdoto: '\u2A82', gesdotol: '\u2A84', gesl: '\u22DB\uFE00', gesles: '\u2A94', gfr: '\uD835\uDD24', gg: '\u226B', ggg: '\u22D9', gimel: '\u2137', gjcy: '\u0453', gl: '\u2277', glE: '\u2A92', gla: '\u2AA5', glj: '\u2AA4', gnE: '\u2269', gnap: '\u2A8A', gnapprox: '\u2A8A', gne: '\u2A88', gneq: '\u2A88', gneqq: '\u2269', gnsim: '\u22E7', gopf: '\uD835\uDD58', grave: '\u0060', gscr: '\u210A', gsim: '\u2273', gsime: '\u2A8E', gsiml: '\u2A90', gt: '\u003E', gtcc: '\u2AA7', gtcir: '\u2A7A', gtdot: '\u22D7', gtlPar: '\u2995', gtquest: '\u2A7C', gtrapprox: '\u2A86', gtrarr: '\u2978', gtrdot: '\u22D7', gtreqless: '\u22DB', gtreqqless: '\u2A8C', gtrless: '\u2277', gtrsim: '\u2273', gvertneqq: '\u2269\uFE00', gvnE: '\u2269\uFE00', hArr: '\u21D4', hairsp: '\u200A', half: '\u00BD', hamilt: '\u210B', hardcy: '\u044A', harr: '\u2194', harrcir: '\u2948', harrw: '\u21AD', hbar: '\u210F', hcirc: '\u0125', hearts: '\u2665', heartsuit: '\u2665', hellip: '\u2026', hercon: '\u22B9', hfr: '\uD835\uDD25', hksearow: '\u2925', hkswarow: '\u2926', hoarr: '\u21FF', homtht: '\u223B', hookleftarrow: '\u21A9', hookrightarrow: '\u21AA', hopf: '\uD835\uDD59', horbar: '\u2015', hscr: '\uD835\uDCBD', hslash: '\u210F', hstrok: '\u0127', hybull: '\u2043', hyphen: '\u2010', iacute: '\u00ED', ic: '\u2063', icirc: '\u00EE', icy: '\u0438', iecy: '\u0435', iexcl: '\u00A1', iff: '\u21D4', ifr: '\uD835\uDD26', igrave: '\u00EC', ii: '\u2148', iiiint: '\u2A0C', iiint: '\u222D', iinfin: '\u29DC', iiota: '\u2129', ijlig: '\u0133', imacr: '\u012B', image: '\u2111', imagline: '\u2110', imagpart: '\u2111', imath: '\u0131', imof: '\u22B7', imped: '\u01B5', in: '\u2208', incare: '\u2105', infin: '\u221E', infintie: '\u29DD', inodot: '\u0131', int: '\u222B', intcal: '\u22BA', integers: '\u2124', intercal: '\u22BA', intlarhk: '\u2A17', intprod: '\u2A3C', iocy: '\u0451', iogon: '\u012F', iopf: '\uD835\uDD5A', iota: '\u03B9', iprod: '\u2A3C', iquest: '\u00BF', iscr: '\uD835\uDCBE', isin: '\u2208', isinE: '\u22F9', isindot: '\u22F5', isins: '\u22F4', isinsv: '\u22F3', isinv: '\u2208', it: '\u2062', itilde: '\u0129', iukcy: '\u0456', iuml: '\u00EF', jcirc: '\u0135', jcy: '\u0439', jfr: '\uD835\uDD27', jmath: '\u0237', jopf: '\uD835\uDD5B', jscr: '\uD835\uDCBF', jsercy: '\u0458', jukcy: '\u0454', kappa: '\u03BA', kappav: '\u03F0', kcedil: '\u0137', kcy: '\u043A', kfr: '\uD835\uDD28', kgreen: '\u0138', khcy: '\u0445', kjcy: '\u045C', kopf: '\uD835\uDD5C', kscr: '\uD835\uDCC0', lAarr: '\u21DA', lArr: '\u21D0', lAtail: '\u291B', lBarr: '\u290E', lE: '\u2266', lEg: '\u2A8B', lHar: '\u2962', lacute: '\u013A', laemptyv: '\u29B4', lagran: '\u2112', lambda: '\u03BB', lang: '\u27E8', langd: '\u2991', langle: '\u27E8', lap: '\u2A85', laquo: '\u00AB', larr: '\u2190', larrb: '\u21E4', larrbfs: '\u291F', larrfs: '\u291D', larrhk: '\u21A9', larrlp: '\u21AB', larrpl: '\u2939', larrsim: '\u2973', larrtl: '\u21A2', lat: '\u2AAB', latail: '\u2919', late: '\u2AAD', lates: '\u2AAD\uFE00', lbarr: '\u290C', lbbrk: '\u2772', lbrace: '\u007B', lbrack: '\u005B', lbrke: '\u298B', lbrksld: '\u298F', lbrkslu: '\u298D', lcaron: '\u013E', lcedil: '\u013C', lceil: '\u2308', lcub: '\u007B', lcy: '\u043B', ldca: '\u2936', ldquo: '\u201C', ldquor: '\u201E', ldrdhar: '\u2967', ldrushar: '\u294B', ldsh: '\u21B2', le: '\u2264', leftarrow: '\u2190', leftarrowtail: '\u21A2', leftharpoondown: '\u21BD', leftharpoonup: '\u21BC', leftleftarrows: '\u21C7', leftrightarrow: '\u2194', leftrightarrows: '\u21C6', leftrightharpoons: '\u21CB', leftrightsquigarrow: '\u21AD', leftthreetimes: '\u22CB', leg: '\u22DA', leq: '\u2264', leqq: '\u2266', leqslant: '\u2A7D', les: '\u2A7D', lescc: '\u2AA8', lesdot: '\u2A7F', lesdoto: '\u2A81', lesdotor: '\u2A83', lesg: '\u22DA\uFE00', lesges: '\u2A93', lessapprox: '\u2A85', lessdot: '\u22D6', lesseqgtr: '\u22DA', lesseqqgtr: '\u2A8B', lessgtr: '\u2276', lesssim: '\u2272', lfisht: '\u297C', lfloor: '\u230A', lfr: '\uD835\uDD29', lg: '\u2276', lgE: '\u2A91', lhard: '\u21BD', lharu: '\u21BC', lharul: '\u296A', lhblk: '\u2584', ljcy: '\u0459', ll: '\u226A', llarr: '\u21C7', llcorner: '\u231E', llhard: '\u296B', lltri: '\u25FA', lmidot: '\u0140', lmoust: '\u23B0', lmoustache: '\u23B0', lnE: '\u2268', lnap: '\u2A89', lnapprox: '\u2A89', lne: '\u2A87', lneq: '\u2A87', lneqq: '\u2268', lnsim: '\u22E6', loang: '\u27EC', loarr: '\u21FD', lobrk: '\u27E6', longleftarrow: '\u27F5', longleftrightarrow: '\u27F7', longmapsto: '\u27FC', longrightarrow: '\u27F6', looparrowleft: '\u21AB', looparrowright: '\u21AC', lopar: '\u2985', lopf: '\uD835\uDD5D', loplus: '\u2A2D', lotimes: '\u2A34', lowast: '\u2217', lowbar: '\u005F', loz: '\u25CA', lozenge: '\u25CA', lozf: '\u29EB', lpar: '\u0028', lparlt: '\u2993', lrarr: '\u21C6', lrcorner: '\u231F', lrhar: '\u21CB', lrhard: '\u296D', lrm: '\u200E', lrtri: '\u22BF', lsaquo: '\u2039', lscr: '\uD835\uDCC1', lsh: '\u21B0', lsim: '\u2272', lsime: '\u2A8D', lsimg: '\u2A8F', lsqb: '\u005B', lsquo: '\u2018', lsquor: '\u201A', lstrok: '\u0142', lt: '\u003C', ltcc: '\u2AA6', ltcir: '\u2A79', ltdot: '\u22D6', lthree: '\u22CB', ltimes: '\u22C9', ltlarr: '\u2976', ltquest: '\u2A7B', ltrPar: '\u2996', ltri: '\u25C3', ltrie: '\u22B4', ltrif: '\u25C2', lurdshar: '\u294A', luruhar: '\u2966', lvertneqq: '\u2268\uFE00', lvnE: '\u2268\uFE00', mDDot: '\u223A', macr: '\u00AF', male: '\u2642', malt: '\u2720', maltese: '\u2720', map: '\u21A6', mapsto: '\u21A6', mapstodown: '\u21A7', mapstoleft: '\u21A4', mapstoup: '\u21A5', marker: '\u25AE', mcomma: '\u2A29', mcy: '\u043C', mdash: '\u2014', measuredangle: '\u2221', mfr: '\uD835\uDD2A', mho: '\u2127', micro: '\u00B5', mid: '\u2223', midast: '\u002A', midcir: '\u2AF0', middot: '\u00B7', minus: '\u2212', minusb: '\u229F', minusd: '\u2238', minusdu: '\u2A2A', mlcp: '\u2ADB', mldr: '\u2026', mnplus: '\u2213', models: '\u22A7', mopf: '\uD835\uDD5E', mp: '\u2213', mscr: '\uD835\uDCC2', mstpos: '\u223E', mu: '\u03BC', multimap: '\u22B8', mumap: '\u22B8', nGg: '\u22D9\u0338', nGt: '\u226B\u20D2', nGtv: '\u226B\u0338', nLeftarrow: '\u21CD', nLeftrightarrow: '\u21CE', nLl: '\u22D8\u0338', nLt: '\u226A\u20D2', nLtv: '\u226A\u0338', nRightarrow: '\u21CF', nVDash: '\u22AF', nVdash: '\u22AE', nabla: '\u2207', nacute: '\u0144', nang: '\u2220\u20D2', nap: '\u2249', napE: '\u2A70\u0338', napid: '\u224B\u0338', napos: '\u0149', napprox: '\u2249', natur: '\u266E', natural: '\u266E', naturals: '\u2115', nbsp: '\u00A0', nbump: '\u224E\u0338', nbumpe: '\u224F\u0338', ncap: '\u2A43', ncaron: '\u0148', ncedil: '\u0146', ncong: '\u2247', ncongdot: '\u2A6D\u0338', ncup: '\u2A42', ncy: '\u043D', ndash: '\u2013', ne: '\u2260', neArr: '\u21D7', nearhk: '\u2924', nearr: '\u2197', nearrow: '\u2197', nedot: '\u2250\u0338', nequiv: '\u2262', nesear: '\u2928', nesim: '\u2242\u0338', nexist: '\u2204', nexists: '\u2204', nfr: '\uD835\uDD2B', ngE: '\u2267\u0338', nge: '\u2271', ngeq: '\u2271', ngeqq: '\u2267\u0338', ngeqslant: '\u2A7E\u0338', nges: '\u2A7E\u0338', ngsim: '\u2275', ngt: '\u226F', ngtr: '\u226F', nhArr: '\u21CE', nharr: '\u21AE', nhpar: '\u2AF2', ni: '\u220B', nis: '\u22FC', nisd: '\u22FA', niv: '\u220B', njcy: '\u045A', nlArr: '\u21CD', nlE: '\u2266\u0338', nlarr: '\u219A', nldr: '\u2025', nle: '\u2270', nleftarrow: '\u219A', nleftrightarrow: '\u21AE', nleq: '\u2270', nleqq: '\u2266\u0338', nleqslant: '\u2A7D\u0338', nles: '\u2A7D\u0338', nless: '\u226E', nlsim: '\u2274', nlt: '\u226E', nltri: '\u22EA', nltrie: '\u22EC', nmid: '\u2224', nopf: '\uD835\uDD5F', not: '\u00AC', notin: '\u2209', notinE: '\u22F9\u0338', notindot: '\u22F5\u0338', notinva: '\u2209', notinvb: '\u22F7', notinvc: '\u22F6', notni: '\u220C', notniva: '\u220C', notnivb: '\u22FE', notnivc: '\u22FD', npar: '\u2226', nparallel: '\u2226', nparsl: '\u2AFD\u20E5', npart: '\u2202\u0338', npolint: '\u2A14', npr: '\u2280', nprcue: '\u22E0', npre: '\u2AAF\u0338', nprec: '\u2280', npreceq: '\u2AAF\u0338', nrArr: '\u21CF', nrarr: '\u219B', nrarrc: '\u2933\u0338', nrarrw: '\u219D\u0338', nrightarrow: '\u219B', nrtri: '\u22EB', nrtrie: '\u22ED', nsc: '\u2281', nsccue: '\u22E1', nsce: '\u2AB0\u0338', nscr: '\uD835\uDCC3', nshortmid: '\u2224', nshortparallel: '\u2226', nsim: '\u2241', nsime: '\u2244', nsimeq: '\u2244', nsmid: '\u2224', nspar: '\u2226', nsqsube: '\u22E2', nsqsupe: '\u22E3', nsub: '\u2284', nsubE: '\u2AC5\u0338', nsube: '\u2288', nsubset: '\u2282\u20D2', nsubseteq: '\u2288', nsubseteqq: '\u2AC5\u0338', nsucc: '\u2281', nsucceq: '\u2AB0\u0338', nsup: '\u2285', nsupE: '\u2AC6\u0338', nsupe: '\u2289', nsupset: '\u2283\u20D2', nsupseteq: '\u2289', nsupseteqq: '\u2AC6\u0338', ntgl: '\u2279', ntilde: '\u00F1', ntlg: '\u2278', ntriangleleft: '\u22EA', ntrianglelefteq: '\u22EC', ntriangleright: '\u22EB', ntrianglerighteq: '\u22ED', nu: '\u03BD', num: '\u0023', numero: '\u2116', numsp: '\u2007', nvDash: '\u22AD', nvHarr: '\u2904', nvap: '\u224D\u20D2', nvdash: '\u22AC', nvge: '\u2265\u20D2', nvgt: '\u003E\u20D2', nvinfin: '\u29DE', nvlArr: '\u2902', nvle: '\u2264\u20D2', nvlt: '\u003C\u20D2', nvltrie: '\u22B4\u20D2', nvrArr: '\u2903', nvrtrie: '\u22B5\u20D2', nvsim: '\u223C\u20D2', nwArr: '\u21D6', nwarhk: '\u2923', nwarr: '\u2196', nwarrow: '\u2196', nwnear: '\u2927', oS: '\u24C8', oacute: '\u00F3', oast: '\u229B', ocir: '\u229A', ocirc: '\u00F4', ocy: '\u043E', odash: '\u229D', odblac: '\u0151', odiv: '\u2A38', odot: '\u2299', odsold: '\u29BC', oelig: '\u0153', ofcir: '\u29BF', ofr: '\uD835\uDD2C', ogon: '\u02DB', ograve: '\u00F2', ogt: '\u29C1', ohbar: '\u29B5', ohm: '\u03A9', oint: '\u222E', olarr: '\u21BA', olcir: '\u29BE', olcross: '\u29BB', oline: '\u203E', olt: '\u29C0', omacr: '\u014D', omega: '\u03C9', omicron: '\u03BF', omid: '\u29B6', ominus: '\u2296', oopf: '\uD835\uDD60', opar: '\u29B7', operp: '\u29B9', oplus: '\u2295', or: '\u2228', orarr: '\u21BB', ord: '\u2A5D', order: '\u2134', orderof: '\u2134', ordf: '\u00AA', ordm: '\u00BA', origof: '\u22B6', oror: '\u2A56', orslope: '\u2A57', orv: '\u2A5B', oscr: '\u2134', oslash: '\u00F8', osol: '\u2298', otilde: '\u00F5', otimes: '\u2297', otimesas: '\u2A36', ouml: '\u00F6', ovbar: '\u233D', par: '\u2225', para: '\u00B6', parallel: '\u2225', parsim: '\u2AF3', parsl: '\u2AFD', part: '\u2202', pcy: '\u043F', percnt: '\u0025', period: '\u002E', permil: '\u2030', perp: '\u22A5', pertenk: '\u2031', pfr: '\uD835\uDD2D', phi: '\u03C6', phiv: '\u03D5', phmmat: '\u2133', phone: '\u260E', pi: '\u03C0', pitchfork: '\u22D4', piv: '\u03D6', planck: '\u210F', planckh: '\u210E', plankv: '\u210F', plus: '\u002B', plusacir: '\u2A23', plusb: '\u229E', pluscir: '\u2A22', plusdo: '\u2214', plusdu: '\u2A25', pluse: '\u2A72', plusmn: '\u00B1', plussim: '\u2A26', plustwo: '\u2A27', pm: '\u00B1', pointint: '\u2A15', popf: '\uD835\uDD61', pound: '\u00A3', pr: '\u227A', prE: '\u2AB3', prap: '\u2AB7', prcue: '\u227C', pre: '\u2AAF', prec: '\u227A', precapprox: '\u2AB7', preccurlyeq: '\u227C', preceq: '\u2AAF', precnapprox: '\u2AB9', precneqq: '\u2AB5', precnsim: '\u22E8', precsim: '\u227E', prime: '\u2032', primes: '\u2119', prnE: '\u2AB5', prnap: '\u2AB9', prnsim: '\u22E8', prod: '\u220F', profalar: '\u232E', profline: '\u2312', profsurf: '\u2313', prop: '\u221D', propto: '\u221D', prsim: '\u227E', prurel: '\u22B0', pscr: '\uD835\uDCC5', psi: '\u03C8', puncsp: '\u2008', qfr: '\uD835\uDD2E', qint: '\u2A0C', qopf: '\uD835\uDD62', qprime: '\u2057', qscr: '\uD835\uDCC6', quaternions: '\u210D', quatint: '\u2A16', quest: '\u003F', questeq: '\u225F', quot: '\u0022', rAarr: '\u21DB', rArr: '\u21D2', rAtail: '\u291C', rBarr: '\u290F', rHar: '\u2964', race: '\u223D\u0331', racute: '\u0155', radic: '\u221A', raemptyv: '\u29B3', rang: '\u27E9', rangd: '\u2992', range: '\u29A5', rangle: '\u27E9', raquo: '\u00BB', rarr: '\u2192', rarrap: '\u2975', rarrb: '\u21E5', rarrbfs: '\u2920', rarrc: '\u2933', rarrfs: '\u291E', rarrhk: '\u21AA', rarrlp: '\u21AC', rarrpl: '\u2945', rarrsim: '\u2974', rarrtl: '\u21A3', rarrw: '\u219D', ratail: '\u291A', ratio: '\u2236', rationals: '\u211A', rbarr: '\u290D', rbbrk: '\u2773', rbrace: '\u007D', rbrack: '\u005D', rbrke: '\u298C', rbrksld: '\u298E', rbrkslu: '\u2990', rcaron: '\u0159', rcedil: '\u0157', rceil: '\u2309', rcub: '\u007D', rcy: '\u0440', rdca: '\u2937', rdldhar: '\u2969', rdquo: '\u201D', rdquor: '\u201D', rdsh: '\u21B3', real: '\u211C', realine: '\u211B', realpart: '\u211C', reals: '\u211D', rect: '\u25AD', reg: '\u00AE', rfisht: '\u297D', rfloor: '\u230B', rfr: '\uD835\uDD2F', rhard: '\u21C1', rharu: '\u21C0', rharul: '\u296C', rho: '\u03C1', rhov: '\u03F1', rightarrow: '\u2192', rightarrowtail: '\u21A3', rightharpoondown: '\u21C1', rightharpoonup: '\u21C0', rightleftarrows: '\u21C4', rightleftharpoons: '\u21CC', rightrightarrows: '\u21C9', rightsquigarrow: '\u219D', rightthreetimes: '\u22CC', ring: '\u02DA', risingdotseq: '\u2253', rlarr: '\u21C4', rlhar: '\u21CC', rlm: '\u200F', rmoust: '\u23B1', rmoustache: '\u23B1', rnmid: '\u2AEE', roang: '\u27ED', roarr: '\u21FE', robrk: '\u27E7', ropar: '\u2986', ropf: '\uD835\uDD63', roplus: '\u2A2E', rotimes: '\u2A35', rpar: '\u0029', rpargt: '\u2994', rppolint: '\u2A12', rrarr: '\u21C9', rsaquo: '\u203A', rscr: '\uD835\uDCC7', rsh: '\u21B1', rsqb: '\u005D', rsquo: '\u2019', rsquor: '\u2019', rthree: '\u22CC', rtimes: '\u22CA', rtri: '\u25B9', rtrie: '\u22B5', rtrif: '\u25B8', rtriltri: '\u29CE', ruluhar: '\u2968', rx: '\u211E', sacute: '\u015B', sbquo: '\u201A', sc: '\u227B', scE: '\u2AB4', scap: '\u2AB8', scaron: '\u0161', sccue: '\u227D', sce: '\u2AB0', scedil: '\u015F', scirc: '\u015D', scnE: '\u2AB6', scnap: '\u2ABA', scnsim: '\u22E9', scpolint: '\u2A13', scsim: '\u227F', scy: '\u0441', sdot: '\u22C5', sdotb: '\u22A1', sdote: '\u2A66', seArr: '\u21D8', searhk: '\u2925', searr: '\u2198', searrow: '\u2198', sect: '\u00A7', semi: '\u003B', seswar: '\u2929', setminus: '\u2216', setmn: '\u2216', sext: '\u2736', sfr: '\uD835\uDD30', sfrown: '\u2322', sharp: '\u266F', shchcy: '\u0449', shcy: '\u0448', shortmid: '\u2223', shortparallel: '\u2225', shy: '\u00AD', sigma: '\u03C3', sigmaf: '\u03C2', sigmav: '\u03C2', sim: '\u223C', simdot: '\u2A6A', sime: '\u2243', simeq: '\u2243', simg: '\u2A9E', simgE: '\u2AA0', siml: '\u2A9D', simlE: '\u2A9F', simne: '\u2246', simplus: '\u2A24', simrarr: '\u2972', slarr: '\u2190', smallsetminus: '\u2216', smashp: '\u2A33', smeparsl: '\u29E4', smid: '\u2223', smile: '\u2323', smt: '\u2AAA', smte: '\u2AAC', smtes: '\u2AAC\uFE00', softcy: '\u044C', sol: '\u002F', solb: '\u29C4', solbar: '\u233F', sopf: '\uD835\uDD64', spades: '\u2660', spadesuit: '\u2660', spar: '\u2225', sqcap: '\u2293', sqcaps: '\u2293\uFE00', sqcup: '\u2294', sqcups: '\u2294\uFE00', sqsub: '\u228F', sqsube: '\u2291', sqsubset: '\u228F', sqsubseteq: '\u2291', sqsup: '\u2290', sqsupe: '\u2292', sqsupset: '\u2290', sqsupseteq: '\u2292', squ: '\u25A1', square: '\u25A1', squarf: '\u25AA', squf: '\u25AA', srarr: '\u2192', sscr: '\uD835\uDCC8', ssetmn: '\u2216', ssmile: '\u2323', sstarf: '\u22C6', star: '\u2606', starf: '\u2605', straightepsilon: '\u03F5', straightphi: '\u03D5', strns: '\u00AF', sub: '\u2282', subE: '\u2AC5', subdot: '\u2ABD', sube: '\u2286', subedot: '\u2AC3', submult: '\u2AC1', subnE: '\u2ACB', subne: '\u228A', subplus: '\u2ABF', subrarr: '\u2979', subset: '\u2282', subseteq: '\u2286', subseteqq: '\u2AC5', subsetneq: '\u228A', subsetneqq: '\u2ACB', subsim: '\u2AC7', subsub: '\u2AD5', subsup: '\u2AD3', succ: '\u227B', succapprox: '\u2AB8', succcurlyeq: '\u227D', succeq: '\u2AB0', succnapprox: '\u2ABA', succneqq: '\u2AB6', succnsim: '\u22E9', succsim: '\u227F', sum: '\u2211', sung: '\u266A', sup1: '\u00B9', sup2: '\u00B2', sup3: '\u00B3', sup: '\u2283', supE: '\u2AC6', supdot: '\u2ABE', supdsub: '\u2AD8', supe: '\u2287', supedot: '\u2AC4', suphsol: '\u27C9', suphsub: '\u2AD7', suplarr: '\u297B', supmult: '\u2AC2', supnE: '\u2ACC', supne: '\u228B', supplus: '\u2AC0', supset: '\u2283', supseteq: '\u2287', supseteqq: '\u2AC6', supsetneq: '\u228B', supsetneqq: '\u2ACC', supsim: '\u2AC8', supsub: '\u2AD4', supsup: '\u2AD6', swArr: '\u21D9', swarhk: '\u2926', swarr: '\u2199', swarrow: '\u2199', swnwar: '\u292A', szlig: '\u00DF', target: '\u2316', tau: '\u03C4', tbrk: '\u23B4', tcaron: '\u0165', tcedil: '\u0163', tcy: '\u0442', tdot: '\u20DB', telrec: '\u2315', tfr: '\uD835\uDD31', there4: '\u2234', therefore: '\u2234', theta: '\u03B8', thetasym: '\u03D1', thetav: '\u03D1', thickapprox: '\u2248', thicksim: '\u223C', thinsp: '\u2009', thkap: '\u2248', thksim: '\u223C', thorn: '\u00FE', tilde: '\u02DC', times: '\u00D7', timesb: '\u22A0', timesbar: '\u2A31', timesd: '\u2A30', tint: '\u222D', toea: '\u2928', top: '\u22A4', topbot: '\u2336', topcir: '\u2AF1', topf: '\uD835\uDD65', topfork: '\u2ADA', tosa: '\u2929', tprime: '\u2034', trade: '\u2122', triangle: '\u25B5', triangledown: '\u25BF', triangleleft: '\u25C3', trianglelefteq: '\u22B4', triangleq: '\u225C', triangleright: '\u25B9', trianglerighteq: '\u22B5', tridot: '\u25EC', trie: '\u225C', triminus: '\u2A3A', triplus: '\u2A39', trisb: '\u29CD', tritime: '\u2A3B', trpezium: '\u23E2', tscr: '\uD835\uDCC9', tscy: '\u0446', tshcy: '\u045B', tstrok: '\u0167', twixt: '\u226C', twoheadleftarrow: '\u219E', twoheadrightarrow: '\u21A0', uArr: '\u21D1', uHar: '\u2963', uacute: '\u00FA', uarr: '\u2191', ubrcy: '\u045E', ubreve: '\u016D', ucirc: '\u00FB', ucy: '\u0443', udarr: '\u21C5', udblac: '\u0171', udhar: '\u296E', ufisht: '\u297E', ufr: '\uD835\uDD32', ugrave: '\u00F9', uharl: '\u21BF', uharr: '\u21BE', uhblk: '\u2580', ulcorn: '\u231C', ulcorner: '\u231C', ulcrop: '\u230F', ultri: '\u25F8', umacr: '\u016B', uml: '\u00A8', uogon: '\u0173', uopf: '\uD835\uDD66', uparrow: '\u2191', updownarrow: '\u2195', upharpoonleft: '\u21BF', upharpoonright: '\u21BE', uplus: '\u228E', upsi: '\u03C5', upsih: '\u03D2', upsilon: '\u03C5', upuparrows: '\u21C8', urcorn: '\u231D', urcorner: '\u231D', urcrop: '\u230E', uring: '\u016F', urtri: '\u25F9', uscr: '\uD835\uDCCA', utdot: '\u22F0', utilde: '\u0169', utri: '\u25B5', utrif: '\u25B4', uuarr: '\u21C8', uuml: '\u00FC', uwangle: '\u29A7', vArr: '\u21D5', vBar: '\u2AE8', vBarv: '\u2AE9', vDash: '\u22A8', vangrt: '\u299C', varepsilon: '\u03F5', varkappa: '\u03F0', varnothing: '\u2205', varphi: '\u03D5', varpi: '\u03D6', varpropto: '\u221D', varr: '\u2195', varrho: '\u03F1', varsigma: '\u03C2', varsubsetneq: '\u228A\uFE00', varsubsetneqq: '\u2ACB\uFE00', varsupsetneq: '\u228B\uFE00', varsupsetneqq: '\u2ACC\uFE00', vartheta: '\u03D1', vartriangleleft: '\u22B2', vartriangleright: '\u22B3', vcy: '\u0432', vdash: '\u22A2', vee: '\u2228', veebar: '\u22BB', veeeq: '\u225A', vellip: '\u22EE', verbar: '\u007C', vert: '\u007C', vfr: '\uD835\uDD33', vltri: '\u22B2', vnsub: '\u2282\u20D2', vnsup: '\u2283\u20D2', vopf: '\uD835\uDD67', vprop: '\u221D', vrtri: '\u22B3', vscr: '\uD835\uDCCB', vsubnE: '\u2ACB\uFE00', vsubne: '\u228A\uFE00', vsupnE: '\u2ACC\uFE00', vsupne: '\u228B\uFE00', vzigzag: '\u299A', wcirc: '\u0175', wedbar: '\u2A5F', wedge: '\u2227', wedgeq: '\u2259', weierp: '\u2118', wfr: '\uD835\uDD34', wopf: '\uD835\uDD68', wp: '\u2118', wr: '\u2240', wreath: '\u2240', wscr: '\uD835\uDCCC', xcap: '\u22C2', xcirc: '\u25EF', xcup: '\u22C3', xdtri: '\u25BD', xfr: '\uD835\uDD35', xhArr: '\u27FA', xharr: '\u27F7', xi: '\u03BE', xlArr: '\u27F8', xlarr: '\u27F5', xmap: '\u27FC', xnis: '\u22FB', xodot: '\u2A00', xopf: '\uD835\uDD69', xoplus: '\u2A01', xotime: '\u2A02', xrArr: '\u27F9', xrarr: '\u27F6', xscr: '\uD835\uDCCD', xsqcup: '\u2A06', xuplus: '\u2A04', xutri: '\u25B3', xvee: '\u22C1', xwedge: '\u22C0', yacute: '\u00FD', yacy: '\u044F', ycirc: '\u0177', ycy: '\u044B', yen: '\u00A5', yfr: '\uD835\uDD36', yicy: '\u0457', yopf: '\uD835\uDD6A', yscr: '\uD835\uDCCE', yucy: '\u044E', yuml: '\u00FF', zacute: '\u017A', zcaron: '\u017E', zcy: '\u0437', zdot: '\u017C', zeetrf: '\u2128', zeta: '\u03B6', zfr: '\uD835\uDD37', zhcy: '\u0436', zigrarr: '\u21DD', zopf: '\uD835\uDD6B', zscr: '\uD835\uDCCF', zwj: '\u200D', zwnj: '\u200C', }; const decodeMap = { '0': 65533, '128': 8364, '130': 8218, '131': 402, '132': 8222, '133': 8230, '134': 8224, '135': 8225, '136': 710, '137': 8240, '138': 352, '139': 8249, '140': 338, '142': 381, '145': 8216, '146': 8217, '147': 8220, '148': 8221, '149': 8226, '150': 8211, '151': 8212, '152': 732, '153': 8482, '154': 353, '155': 8250, '156': 339, '158': 382, '159': 376, }; function decodeHTMLStrict(text) { return text.replace(/&(?:[a-zA-Z]+|#[xX][\da-fA-F]+|#\d+);/g, (key) => { if (key.charAt(1) === '#') { const secondChar = key.charAt(2); const codePoint = secondChar === 'X' || secondChar === 'x' ? parseInt(key.slice(3), 16) : parseInt(key.slice(2), 10); return decodeCodePoint(codePoint); } return getOwnProperty(entities, key.slice(1, -1)) ?? key; }); } function decodeCodePoint(codePoint) { if ((codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) { return '\uFFFD'; } return String.fromCodePoint(getOwnProperty(decodeMap, codePoint) ?? codePoint); } function scanJSXAttributeValue(parser, context) { parser.startIndex = parser.tokenIndex = parser.index; parser.startColumn = parser.tokenColumn = parser.column; parser.startLine = parser.tokenLine = parser.line; parser.setToken(CharTypes[parser.currentChar] & 8192 ? scanJSXString(parser) : scanSingleToken(parser, context, 0)); return parser.getToken(); } function scanJSXString(parser) { const quote = parser.currentChar; let char = advanceChar(parser); const start = parser.index; while (char !== quote) { if (parser.index >= parser.end) parser.report(16); char = advanceChar(parser); } if (char !== quote) parser.report(16); parser.tokenValue = parser.source.slice(start, parser.index); advanceChar(parser); if (parser.options.raw) parser.tokenRaw = parser.source.slice(parser.tokenIndex, parser.index); return 134283267; } function nextJSXToken(parser) { parser.startIndex = parser.tokenIndex = parser.index; parser.startColumn = parser.tokenColumn = parser.column; parser.startLine = parser.tokenLine = parser.line; if (parser.index >= parser.end) { parser.setToken(1048576); return; } if (parser.currentChar === 60) { advanceChar(parser); parser.setToken(8456256); return; } if (parser.currentChar === 123) { advanceChar(parser); parser.setToken(2162700); return; } let state = 0; while (parser.index < parser.end) { const type = CharTypes[parser.source.charCodeAt(parser.index)]; if (type & 1024) { state |= 1 | 4; scanNewLine(parser); } else if (type & 2048) { consumeLineFeed(parser, state); state = (state & -5) | 1; } else { advanceChar(parser); } if (CharTypes[parser.currentChar] & 16384) break; } if (parser.tokenIndex === parser.index) parser.report(0); const raw = parser.source.slice(parser.tokenIndex, parser.index); if (parser.options.raw) parser.tokenRaw = raw; parser.tokenValue = decodeHTMLStrict(raw); parser.setToken(137); } function rescanJSXIdentifier(parser) { if ((parser.getToken() & 143360) === 143360) { const { index } = parser; let char = parser.currentChar; while (CharTypes[char] & (32768 | 2)) { char = advanceChar(parser); } parser.tokenValue += parser.source.slice(index, parser.index); parser.setToken(208897, true); } return parser.getToken(); } class Scope { parser; type; parent; scopeError; variableBindings = new Map(); constructor(parser, type = 2, parent) { this.parser = parser; this.type = type; this.parent = parent; } createChildScope(type) { return new Scope(this.parser, type, this); } addVarOrBlock(context, name, kind, origin) { if (kind & 4) { this.addVarName(context, name, kind); } else { this.addBlockName(context, name, kind, origin); } if (origin & 64) { this.parser.declareUnboundVariable(name); } } addVarName(context, name, kind) { const { parser } = this; let currentScope = this; while (currentScope && (currentScope.type & 128) === 0) { const { variableBindings } = currentScope; const value = variableBindings.get(name); if (value && value & 248) { if (parser.options.webcompat && (context & 1) === 0 && ((kind & 128 && value & 68) || (value & 128 && kind & 68))) ; else { parser.report(145, name); } } if (currentScope === this) { if (value && value & 1 && kind & 1) { currentScope.recordScopeError(145, name); } } if (value && (value & 256 || (value & 512 && !parser.options.webcompat))) { parser.report(145, name); } currentScope.variableBindings.set(name, kind); currentScope = currentScope.parent; } } hasVariable(name) { return this.variableBindings.has(name); } addBlockName(context, name, kind, origin) { const { parser } = this; const value = this.variableBindings.get(name); if (value && (value & 2) === 0) { if (kind & 1) { this.recordScopeError(145, name); } else if (parser.options.webcompat && (context & 1) === 0 && origin & 2 && value === 64 && kind === 64) ; else { parser.report(145, name); } } if (this.type & 64 && this.parent?.hasVariable(name) && (this.parent.variableBindings.get(name) & 2) === 0) { parser.report(145, name); } if (this.type & 512 && value && (value & 2) === 0) { if (kind & 1) { this.recordScopeError(145, name); } } if (this.type & 32) { if (this.parent.variableBindings.get(name) & 768) parser.report(159, name); } this.variableBindings.set(name, kind); } recordScopeError(type, ...params) { this.scopeError = { type, params, start: this.parser.tokenStart, end: this.parser.currentLocation, }; } reportScopeError() { const { scopeError } = this; if (!scopeError) { return; } throw new ParseError(scopeError.start, scopeError.end, scopeError.type, ...scopeError.params); } } function createArrowHeadParsingScope(parser, context, value) { const scope = parser.createScope().createChildScope(512); scope.addBlockName(context, value, 1, 0); return scope; } class PrivateScope { parser; parent; refs = Object.create(null); privateIdentifiers = new Map(); constructor(parser, parent) { this.parser = parser; this.parent = parent; } addPrivateIdentifier(name, kind) { const { privateIdentifiers } = this; let focusKind = kind & (32 | 768); if (!(focusKind & 768)) focusKind |= 768; const value = privateIdentifiers.get(name); if (this.hasPrivateIdentifier(name) && ((value & 32) !== (focusKind & 32) || value & focusKind & 768)) { this.parser.report(146, name); } privateIdentifiers.set(name, this.hasPrivateIdentifier(name) ? value | focusKind : focusKind); } addPrivateIdentifierRef(name) { this.refs[name] ??= []; this.refs[name].push(this.parser.tokenStart); } isPrivateIdentifierDefined(name) { return this.hasPrivateIdentifier(name) || Boolean(this.parent?.isPrivateIdentifierDefined(name)); } validatePrivateIdentifierRefs() { for (const name in this.refs) { if (!this.isPrivateIdentifierDefined(name)) { const { index, line, column } = this.refs[name][0]; throw new ParseError({ index, line, column }, { index: index + name.length, line, column: column + name.length }, 4, name); } } } hasPrivateIdentifier(name) { return this.privateIdentifiers.has(name); } } class Parser { source; options; lastOnToken = null; token = 1048576; flags = 0; index = 0; line = 1; column = 0; startIndex = 0; end = 0; tokenIndex = 0; startColumn = 0; tokenColumn = 0; tokenLine = 1; startLine = 1; tokenValue = ''; tokenRaw = ''; tokenRegExp = void 0; currentChar = 0; exportedNames = new Set(); exportedBindings = new Set(); assignable = 1; destructible = 0; leadingDecorators = { decorators: [] }; constructor(source, options = {}) { this.source = source; this.options = options; this.end = source.length; this.currentChar = source.charCodeAt(0); } getToken() { return this.token; } setToken(value, replaceLast = false) { this.token = value; const { onToken } = this.options; if (onToken) { if (value !== 1048576) { const loc = { start: { line: this.tokenLine, column: this.tokenColumn, }, end: { line: this.line, column: this.column, }, }; if (!replaceLast && this.lastOnToken) { onToken(...this.lastOnToken); } this.lastOnToken = [convertTokenType(value), this.tokenIndex, this.index, loc]; } else { if (this.lastOnToken) { onToken(...this.lastOnToken); this.lastOnToken = null; } } } return value; } get tokenStart() { return { index: this.tokenIndex, line: this.tokenLine, column: this.tokenColumn, }; } get currentLocation() { return { index: this.index, line: this.line, column: this.column }; } finishNode(node, start, end) { if (this.options.ranges) { node.start = start.index; const endIndex = end ? end.index : this.startIndex; node.end = endIndex; node.range = [start.index, endIndex]; } if (this.options.loc) { node.loc = { start: { line: start.line, column: start.column, }, end: end ? { line: end.line, column: end.column } : { line: this.startLine, column: this.startColumn }, }; if (this.options.source) { node.loc.source = this.options.source; } } return node; } addBindingToExports(name) { this.exportedBindings.add(name); } declareUnboundVariable(name) { const { exportedNames } = this; if (exportedNames.has(name)) { this.report(147, name); } exportedNames.add(name); } report(type, ...params) { throw new ParseError(this.tokenStart, this.currentLocation, type, ...params); } createScopeIfLexical(type, parent) { if (this.options.lexical) { return this.createScope(type, parent); } return undefined; } createScope(type, parent) { return new Scope(this, type, parent); } createPrivateScopeIfLexical(parent) { if (this.options.lexical) { return new PrivateScope(this, parent); } return undefined; } } function pushComment(comments, options) { return function (type, value, start, end, loc) { const comment = { type, value, }; if (options.ranges) { comment.start = start; comment.end = end; comment.range = [start, end]; } if (options.loc) { comment.loc = loc; } comments.push(comment); }; } function pushToken(tokens, options) { return function (type, start, end, loc) { const token = { token: type, }; if (options.ranges) { token.start = start; token.end = end; token.range = [start, end]; } if (options.loc) { token.loc = loc; } tokens.push(token); }; } function normalizeOptions(rawOptions) { const options = { ...rawOptions }; if (options.onComment) { options.onComment = Array.isArray(options.onComment) ? pushComment(options.onComment, options) : options.onComment; } if (options.onToken) { options.onToken = Array.isArray(options.onToken) ? pushToken(options.onToken, options) : options.onToken; } return options; } function parseSource(source, rawOptions = {}, context = 0) { const options = normalizeOptions(rawOptions); if (options.module) context |= 2 | 1; if (options.globalReturn) context |= 4096; if (options.impliedStrict) context |= 1; const parser = new Parser(source, options); skipHashBang(parser); const scope = parser.createScopeIfLexical(); let body = []; let sourceType = 'script'; if (context & 2) { sourceType = 'module'; body = parseModuleItemList(parser, context | 8, scope); if (scope) { for (const name of parser.exportedBindings) { if (!scope.hasVariable(name)) parser.report(148, name); } } } else { body = parseStatementList(parser, context | 8, scope); } return parser.finishNode({ type: 'Program', sourceType, body, }, { index: 0, line: 1, column: 0 }, parser.currentLocation); } function parseStatementList(parser, context, scope) { nextToken(parser, context | 32 | 262144); const statements = []; while (parser.getToken() === 134283267) { const { index, tokenValue, tokenStart, tokenIndex } = parser; const token = parser.getToken(); const expr = parseLiteral(parser, context); if (isValidStrictMode(parser, index, tokenIndex, tokenValue)) { context |= 1; if (parser.flags & 64) { throw new ParseError(parser.tokenStart, parser.currentLocation, 9); } if (parser.flags & 4096) { throw new ParseError(parser.tokenStart, parser.currentLocation, 15); } } statements.push(parseDirective(parser, context, expr, token, tokenStart)); } while (parser.getToken() !== 1048576) { statements.push(parseStatementListItem(parser, context, scope, undefined, 4, {})); } return statements; } function parseModuleItemList(parser, context, scope) { nextToken(parser, context | 32); const statements = []; while (parser.getToken() === 134283267) { const { tokenStart } = parser; const token = parser.getToken(); statements.push(parseDirective(parser, context, parseLiteral(parser, context), token, tokenStart)); } while (parser.getToken() !== 1048576) { statements.push(parseModuleItem(parser, context, scope)); } return statements; } function parseModuleItem(parser, context, scope) { if (parser.getToken() === 132) { Object.assign(parser.leadingDecorators, { start: parser.tokenStart, decorators: parseDecorators(parser, context, undefined), }); } let moduleItem; switch (parser.getToken()) { case 20564: moduleItem = parseExportDeclaration(parser, context, scope); break; case 86106: moduleItem = parseImportDeclaration(parser, context, scope); break; default: moduleItem = parseStatementListItem(parser, context, scope, undefined, 4, {}); } if (parser.leadingDecorators?.decorators.length) { parser.report(170); } return moduleItem; } function parseStatementListItem(parser, context, scope, privateScope, origin, labels) { const start = parser.tokenStart; switch (parser.getToken()) { case 86104: return parseFunctionDeclaration(parser, context, scope, privateScope, origin, 1, 0, 0, start); case 132: case 86094: return parseClassDeclaration(parser, context, scope, privateScope, 0); case 86090: return parseLexicalDeclaration(parser, context, scope, privateScope, 16, 0); case 241737: return parseLetIdentOrVarDeclarationStatement(parser, context, scope, privateScope, origin); case 20564: parser.report(103, 'export'); case 86106: nextToken(parser, context); switch (parser.getToken()) { case 67174411: return parseImportCallDeclaration(parser, context, privateScope, start); case 67108877: return parseImportMetaDeclaration(parser, context, start); default: parser.report(103, 'import'); } case 209005: return parseAsyncArrowOrAsyncFunctionDeclaration(parser, context, scope, privateScope, origin, labels, 1); default: return parseStatement(parser, context, scope, privateScope, origin, labels, 1); } } function parseStatement(parser, context, scope, privateScope, origin, labels, allowFuncDecl) { switch (parser.getToken()) { case 86088: return parseVariableStatement(parser, context, scope, privateScope, 0); case 20572: return parseReturnStatement(parser, context, privateScope); case 20569: return parseIfStatement(parser, context, scope, privateScope, labels); case 20567: return parseForStatement(parser, context, scope, privateScope, labels); case 20562: return parseDoWhileStatement(parser, context, scope, privateScope, labels); case 20578: return parseWhileStatement(parser, context, scope, privateScope, labels); case 86110: return parseSwitchStatement(parser, context, scope, privateScope, labels); case 1074790417: return parseEmptyStatement(parser, context); case 2162700: return parseBlock(parser, context, scope?.createChildScope(), privateScope, labels, parser.tokenStart); case 86112: return parseThrowStatement(parser, context, privateScope); case 20555: return parseBreakStatement(parser, context, labels); case 20559: return parseContinueStatement(parser, context, labels); case 20577: return parseTryStatement(parser, context, scope, privateScope, labels); case 20579: return parseWithStatement(parser, context, scope, privateScope, labels); case 20560: return parseDebuggerStatement(parser, context); case 209005: return parseAsyncArrowOrAsyncFunctionDeclaration(parser, context, scope, privateScope, origin, labels, 0); case 20557: parser.report(162); case 20566: parser.report(163); case 86104: parser.report(context & 1 ? 76 : !parser.options.webcompat ? 78 : 77); case 86094: parser.report(79); default: return parseExpressionOrLabelledStatement(parser, context, scope, privateScope, origin, labels, allowFuncDecl); } } function parseExpressionOrLabelledStatement(parser, context, scope, privateScope, origin, labels, allowFuncDecl) { const { tokenValue, tokenStart } = parser; const token = parser.getToken(); let expr; switch (token) { case 241737: expr = parseIdentifier(parser, context); if (context & 1) parser.report(85); if (parser.getToken() === 69271571) parser.report(84); break; default: expr = parsePrimaryExpression(parser, context, privateScope, 2, 0, 1, 0, 1, parser.tokenStart); } if (token & 143360 && parser.getToken() === 21) { return parseLabelledStatement(parser, context, scope, privateScope, origin, labels, tokenValue, expr, token, allowFuncDecl, tokenStart); } expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, 0, 0, tokenStart); expr = parseAssignmentExpression(parser, context, privateScope, 0, 0, tokenStart, expr); if (parser.getToken() === 18) { expr = parseSequenceExpression(parser, context, privateScope, 0, tokenStart, expr); } return parseExpressionStatement(parser, context, expr, tokenStart); } function parseBlock(parser, context, scope, privateScope, labels, start = parser.tokenStart, type = 'BlockStatement') { const body = []; consume(parser, context | 32, 2162700); while (parser.getToken() !== 1074790415) { body.push(parseStatementListItem(parser, context, scope, privateScope, 2, { $: labels })); } consume(parser, context | 32, 1074790415); return parser.finishNode({ type, body, }, start); } function parseReturnStatement(parser, context, privateScope) { if ((context & 4096) === 0) parser.report(92); const start = parser.tokenStart; nextToken(parser, context | 32); const argument = parser.flags & 1 || parser.getToken() & 1048576 ? null : parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); matchOrInsertSemicolon(parser, context | 32); return parser.finishNode({ type: 'ReturnStatement', argument, }, start); } function parseExpressionStatement(parser, context, expression, start) { matchOrInsertSemicolon(parser, context | 32); return parser.finishNode({ type: 'ExpressionStatement', expression, }, start); } function parseLabelledStatement(parser, context, scope, privateScope, origin, labels, value, expr, token, allowFuncDecl, start) { validateBindingIdentifier(parser, context, 0, token, 1); validateAndDeclareLabel(parser, labels, value); nextToken(parser, context | 32); const body = allowFuncDecl && (context & 1) === 0 && parser.options.webcompat && parser.getToken() === 86104 ? parseFunctionDeclaration(parser, context, scope?.createChildScope(), privateScope, origin, 0, 0, 0, parser.tokenStart) : parseStatement(parser, context, scope, privateScope, origin, labels, allowFuncDecl); return parser.finishNode({ type: 'LabeledStatement', label: expr, body, }, start); } function parseAsyncArrowOrAsyncFunctionDeclaration(parser, context, scope, privateScope, origin, labels, allowFuncDecl) { const { tokenValue, tokenStart: start } = parser; const token = parser.getToken(); let expr = parseIdentifier(parser, context); if (parser.getToken() === 21) { return parseLabelledStatement(parser, context, scope, privateScope, origin, labels, tokenValue, expr, token, 1, start); } const asyncNewLine = parser.flags & 1; if (!asyncNewLine) { if (parser.getToken() === 86104) { if (!allowFuncDecl) parser.report(123); return parseFunctionDeclaration(parser, context, scope, privateScope, origin, 1, 0, 1, start); } if (isValidIdentifier(context, parser.getToken())) { expr = parseAsyncArrowAfterIdent(parser, context, privateScope, 1, start); if (parser.getToken() === 18) expr = parseSequenceExpression(parser, context, privateScope, 0, start, expr); return parseExpressionStatement(parser, context, expr, start); } } if (parser.getToken() === 67174411) { expr = parseAsyncArrowOrCallExpression(parser, context, privateScope, expr, 1, 1, 0, asyncNewLine, start); } else { if (parser.getToken() === 10) { classifyIdentifier(parser, context, token); if ((token & 36864) === 36864) { parser.flags |= 256; } expr = parseArrowFromIdentifier(parser, context | 2048, privateScope, parser.tokenValue, expr, 0, 1, 0, start); } parser.assignable = 1; } expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, 0, 0, start); expr = parseAssignmentExpression(parser, context, privateScope, 0, 0, start, expr); parser.assignable = 1; if (parser.getToken() === 18) { expr = parseSequenceExpression(parser, context, privateScope, 0, start, expr); } return parseExpressionStatement(parser, context, expr, start); } function parseDirective(parser, context, expression, token, start) { const endIndex = parser.startIndex; if (token !== 1074790417) { parser.assignable = 2; expression = parseMemberOrUpdateExpression(parser, context, undefined, expression, 0, 0, start); if (parser.getToken() !== 1074790417) { expression = parseAssignmentExpression(parser, context, undefined, 0, 0, start, expression); if (parser.getToken() === 18) { expression = parseSequenceExpression(parser, context, undefined, 0, start, expression); } } matchOrInsertSemicolon(parser, context | 32); } const node = { type: 'ExpressionStatement', expression, }; if (expression.type === 'Literal' && typeof expression.value === 'string') { node.directive = parser.source.slice(start.index + 1, endIndex - 1); } return parser.finishNode(node, start); } function parseEmptyStatement(parser, context) { const start = parser.tokenStart; nextToken(parser, context | 32); return parser.finishNode({ type: 'EmptyStatement', }, start); } function parseThrowStatement(parser, context, privateScope) { const start = parser.tokenStart; nextToken(parser, context | 32); if (parser.flags & 1) parser.report(90); const argument = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); matchOrInsertSemicolon(parser, context | 32); return parser.finishNode({ type: 'ThrowStatement', argument, }, start); } function parseIfStatement(parser, context, scope, privateScope, labels) { const start = parser.tokenStart; nextToken(parser, context); consume(parser, context | 32, 67174411); parser.assignable = 1; const test = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); consume(parser, context | 32, 16); const consequent = parseConsequentOrAlternative(parser, context, scope, privateScope, labels); let alternate = null; if (parser.getToken() === 20563) { nextToken(parser, context | 32); alternate = parseConsequentOrAlternative(parser, context, scope, privateScope, labels); } return parser.finishNode({ type: 'IfStatement', test, consequent, alternate, }, start); } function parseConsequentOrAlternative(parser, context, scope, privateScope, labels) { const { tokenStart } = parser; return context & 1 || !parser.options.webcompat || parser.getToken() !== 86104 ? parseStatement(parser, context, scope, privateScope, 0, { $: labels }, 0) : parseFunctionDeclaration(parser, context, scope?.createChildScope(), privateScope, 0, 0, 0, 0, tokenStart); } function parseSwitchStatement(parser, context, scope, privateScope, labels) { const start = parser.tokenStart; nextToken(parser, context); consume(parser, context | 32, 67174411); const discriminant = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); consume(parser, context, 16); consume(parser, context, 2162700); const cases = []; let seenDefault = 0; scope = scope?.createChildScope(8); while (parser.getToken() !== 1074790415) { const { tokenStart } = parser; let test = null; const consequent = []; if (consumeOpt(parser, context | 32, 20556)) { test = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); } else { consume(parser, context | 32, 20561); if (seenDefault) parser.report(89); seenDefault = 1; } consume(parser, context | 32, 21); while (parser.getToken() !== 20556 && parser.getToken() !== 1074790415 && parser.getToken() !== 20561) { consequent.push(parseStatementListItem(parser, context | 4, scope, privateScope, 2, { $: labels, })); } cases.push(parser.finishNode({ type: 'SwitchCase', test, consequent, }, tokenStart)); } consume(parser, context | 32, 1074790415); return parser.finishNode({ type: 'SwitchStatement', discriminant, cases, }, start); } function parseWhileStatement(parser, context, scope, privateScope, labels) { const start = parser.tokenStart; nextToken(parser, context); consume(parser, context | 32, 67174411); const test = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); consume(parser, context | 32, 16); const body = parseIterationStatementBody(parser, context, scope, privateScope, labels); return parser.finishNode({ type: 'WhileStatement', test, body, }, start); } function parseIterationStatementBody(parser, context, scope, privateScope, labels) { return parseStatement(parser, ((context | 131072) ^ 131072) | 128, scope, privateScope, 0, { loop: 1, $: labels }, 0); } function parseContinueStatement(parser, context, labels) { if ((context & 128) === 0) parser.report(68); const start = parser.tokenStart; nextToken(parser, context); let label = null; if ((parser.flags & 1) === 0 && parser.getToken() & 143360) { const { tokenValue } = parser; label = parseIdentifier(parser, context | 32); if (!isValidLabel(parser, labels, tokenValue, 1)) parser.report(138, tokenValue); } matchOrInsertSemicolon(parser, context | 32); return parser.finishNode({ type: 'ContinueStatement', label, }, start); } function parseBreakStatement(parser, context, labels) { const start = parser.tokenStart; nextToken(parser, context | 32); let label = null; if ((parser.flags & 1) === 0 && parser.getToken() & 143360) { const { tokenValue } = parser; label = parseIdentifier(parser, context | 32); if (!isValidLabel(parser, labels, tokenValue, 0)) parser.report(138, tokenValue); } else if ((context & (4 | 128)) === 0) { parser.report(69); } matchOrInsertSemicolon(parser, context | 32); return parser.finishNode({ type: 'BreakStatement', label, }, start); } function parseWithStatement(parser, context, scope, privateScope, labels) { const start = parser.tokenStart; nextToken(parser, context); if (context & 1) parser.report(91); consume(parser, context | 32, 67174411); const object = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); consume(parser, context | 32, 16); const body = parseStatement(parser, context, scope, privateScope, 2, labels, 0); return parser.finishNode({ type: 'WithStatement', object, body, }, start); } function parseDebuggerStatement(parser, context) { const start = parser.tokenStart; nextToken(parser, context | 32); matchOrInsertSemicolon(parser, context | 32); return parser.finishNode({ type: 'DebuggerStatement', }, start); } function parseTryStatement(parser, context, scope, privateScope, labels) { const start = parser.tokenStart; nextToken(parser, context | 32); const firstScope = scope?.createChildScope(16); const block = parseBlock(parser, context, firstScope, privateScope, { $: labels }); const { tokenStart } = parser; const handler = consumeOpt(parser, context | 32, 20557) ? parseCatchBlock(parser, context, scope, privateScope, labels, tokenStart) : null; let finalizer = null; if (parser.getToken() === 20566) { nextToken(parser, context | 32); const finalizerScope = scope?.createChildScope(4); const block = parseBlock(parser, context, finalizerScope, privateScope, { $: labels }); finalizer = block; } if (!handler && !finalizer) { parser.report(88); } return parser.finishNode({ type: 'TryStatement', block, handler, finalizer, }, start); } function parseCatchBlock(parser, context, scope, privateScope, labels, start) { let param = null; let additionalScope = scope; if (consumeOpt(parser, context, 67174411)) { scope = scope?.createChildScope(4); param = parseBindingPattern(parser, context, scope, privateScope, (parser.getToken() & 2097152) === 2097152 ? 256 : 512, 0); if (parser.getToken() === 18) { parser.report(86); } else if (parser.getToken() === 1077936155) { parser.report(87); } consume(parser, context | 32, 16); } additionalScope = scope?.createChildScope(32); const body = parseBlock(parser, context, additionalScope, privateScope, { $: labels }); return parser.finishNode({ type: 'CatchClause', param, body, }, start); } function parseStaticBlock(parser, context, scope, privateScope, start) { scope = scope?.createChildScope(); const ctorContext = 512 | 4096 | 1024 | 4 | 128; context = ((context | ctorContext) ^ ctorContext) | 256 | 2048 | 524288 | 65536; return parseBlock(parser, context, scope, privateScope, {}, start, 'StaticBlock'); } function parseDoWhileStatement(parser, context, scope, privateScope, labels) { const start = parser.tokenStart; nextToken(parser, context | 32); const body = parseIterationStatementBody(parser, context, scope, privateScope, labels); consume(parser, context, 20578); consume(parser, context | 32, 67174411); const test = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); consume(parser, context | 32, 16); consumeOpt(parser, context | 32, 1074790417); return parser.finishNode({ type: 'DoWhileStatement', body, test, }, start); } function parseLetIdentOrVarDeclarationStatement(parser, context, scope, privateScope, origin) { const { tokenValue, tokenStart } = parser; const token = parser.getToken(); let expr = parseIdentifier(parser, context); if (parser.getToken() & (143360 | 2097152)) { const declarations = parseVariableDeclarationList(parser, context, scope, privateScope, 8, 0); matchOrInsertSemicolon(parser, context | 32); return parser.finishNode({ type: 'VariableDeclaration', kind: 'let', declarations, }, tokenStart); } parser.assignable = 1; if (context & 1) parser.report(85); if (parser.getToken() === 21) { return parseLabelledStatement(parser, context, scope, privateScope, origin, {}, tokenValue, expr, token, 0, tokenStart); } if (parser.getToken() === 10) { let scope = void 0; if (parser.options.lexical) scope = createArrowHeadParsingScope(parser, context, tokenValue); parser.flags = (parser.flags | 128) ^ 128; expr = parseArrowFunctionExpression(parser, context, scope, privateScope, [expr], 0, tokenStart); } else { expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, 0, 0, tokenStart); expr = parseAssignmentExpression(parser, context, privateScope, 0, 0, tokenStart, expr); } if (parser.getToken() === 18) { expr = parseSequenceExpression(parser, context, privateScope, 0, tokenStart, expr); } return parseExpressionStatement(parser, context, expr, tokenStart); } function parseLexicalDeclaration(parser, context, scope, privateScope, kind, origin) { const start = parser.tokenStart; nextToken(parser, context); const declarations = parseVariableDeclarationList(parser, context, scope, privateScope, kind, origin); matchOrInsertSemicolon(parser, context | 32); return parser.finishNode({ type: 'VariableDeclaration', kind: kind & 8 ? 'let' : 'const', declarations, }, start); } function parseVariableStatement(parser, context, scope, privateScope, origin) { const start = parser.tokenStart; nextToken(parser, context); const declarations = parseVariableDeclarationList(parser, context, scope, privateScope, 4, origin); matchOrInsertSemicolon(parser, context | 32); return parser.finishNode({ type: 'VariableDeclaration', kind: 'var', declarations, }, start); } function parseVariableDeclarationList(parser, context, scope, privateScope, kind, origin) { let bindingCount = 1; const list = [ parseVariableDeclaration(parser, context, scope, privateScope, kind, origin), ]; while (consumeOpt(parser, context, 18)) { bindingCount++; list.push(parseVariableDeclaration(parser, context, scope, privateScope, kind, origin)); } if (bindingCount > 1 && origin & 32 && parser.getToken() & 262144) { parser.report(61, KeywordDescTable[parser.getToken() & 255]); } return list; } function parseVariableDeclaration(parser, context, scope, privateScope, kind, origin) { const { tokenStart } = parser; const token = parser.getToken(); let init = null; const id = parseBindingPattern(parser, context, scope, privateScope, kind, origin); if (parser.getToken() === 1077936155) { nextToken(parser, context | 32); init = parseExpression(parser, context, privateScope, 1, 0, parser.tokenStart); if (origin & 32 || (token & 2097152) === 0) { if (parser.getToken() === 471156 || (parser.getToken() === 8673330 && (token & 2097152 || (kind & 4) === 0 || context & 1))) { throw new ParseError(tokenStart, parser.currentLocation, 60, parser.getToken() === 471156 ? 'of' : 'in'); } } } else if ((kind & 16 || (token & 2097152) > 0) && (parser.getToken() & 262144) !== 262144) { parser.report(59, kind & 16 ? 'const' : 'destructuring'); } return parser.finishNode({ type: 'VariableDeclarator', id, init, }, tokenStart); } function parseForStatement(parser, context, scope, privateScope, labels) { const start = parser.tokenStart; nextToken(parser, context); const forAwait = ((context & 2048) > 0 || ((context & 2) > 0 && (context & 8) > 0)) && consumeOpt(parser, context, 209006); consume(parser, context | 32, 67174411); scope = scope?.createChildScope(1); let test = null; let update = null; let destructible = 0; let init = null; let isVarDecl = parser.getToken() === 86088 || parser.getToken() === 241737 || parser.getToken() === 86090; let right; const { tokenStart } = parser; const token = parser.getToken(); if (isVarDecl) { if (token === 241737) { init = parseIdentifier(parser, context); if (parser.getToken() & (143360 | 2097152)) { if (parser.getToken() === 8673330) { if (context & 1) parser.report(67); } else { init = parser.finishNode({ type: 'VariableDeclaration', kind: 'let', declarations: parseVariableDeclarationList(parser, context | 131072, scope, privateScope, 8, 32), }, tokenStart); } parser.assignable = 1; } else if (context & 1) { parser.report(67); } else { isVarDecl = false; parser.assignable = 1; init = parseMemberOrUpdateExpression(parser, context, privateScope, init, 0, 0, tokenStart); if (parser.getToken() === 471156) parser.report(115); } } else { nextToken(parser, context); init = parser.finishNode(token === 86088 ? { type: 'VariableDeclaration', kind: 'var', declarations: parseVariableDeclarationList(parser, context | 131072, scope, privateScope, 4, 32), } : { type: 'VariableDeclaration', kind: 'const', declarations: parseVariableDeclarationList(parser, context | 131072, scope, privateScope, 16, 32), }, tokenStart); parser.assignable = 1; } } else if (token === 1074790417) { if (forAwait) parser.report(82); } else if ((token & 2097152) === 2097152) { const patternStart = parser.tokenStart; init = token === 2162700 ? parseObjectLiteralOrPattern(parser, context, void 0, privateScope, 1, 0, 0, 2, 32) : parseArrayExpressionOrPattern(parser, context, void 0, privateScope, 1, 0, 0, 2, 32); destructible = parser.destructible; if (destructible & 64) { parser.report(63); } parser.assignable = destructible & 16 ? 2 : 1; init = parseMemberOrUpdateExpression(parser, context | 131072, privateScope, init, 0, 0, patternStart); } else { init = parseLeftHandSideExpression(parser, context | 131072, privateScope, 1, 0, 1); } if ((parser.getToken() & 262144) === 262144) { if (parser.getToken() === 471156) { if (parser.assignable & 2) parser.report(80, forAwait ? 'await' : 'of'); reinterpretToPattern(parser, init); nextToken(parser, context | 32); right = parseExpression(parser, context, privateScope, 1, 0, parser.tokenStart); consume(parser, context | 32, 16); const body = parseIterationStatementBody(parser, context, scope, privateScope, labels); return parser.finishNode({ type: 'ForOfStatement', left: init, right, body, await: forAwait, }, start); } if (parser.assignable & 2) parser.report(80, 'in'); reinterpretToPattern(parser, init); nextToken(parser, context | 32); if (forAwait) parser.report(82); right = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); consume(parser, context | 32, 16); const body = parseIterationStatementBody(parser, context, scope, privateScope, labels); return parser.finishNode({ type: 'ForInStatement', body, left: init, right, }, start); } if (forAwait) parser.report(82); if (!isVarDecl) { if (destructible & 8 && parser.getToken() !== 1077936155) { parser.report(80, 'loop'); } init = parseAssignmentExpression(parser, context | 131072, privateScope, 0, 0, tokenStart, init); } if (parser.getToken() === 18) init = parseSequenceExpression(parser, context, privateScope, 0, tokenStart, init); consume(parser, context | 32, 1074790417); if (parser.getToken() !== 1074790417) test = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); consume(parser, context | 32, 1074790417); if (parser.getToken() !== 16) update = parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart); consume(parser, context | 32, 16); const body = parseIterationStatementBody(parser, context, scope, privateScope, labels); return parser.finishNode({ type: 'ForStatement', init, test, update, body, }, start); } function parseRestrictedIdentifier(parser, context, scope) { if (!isValidIdentifier(context, parser.getToken())) parser.report(118); if ((parser.getToken() & 537079808) === 537079808) parser.report(119); scope?.addBlockName(context, parser.tokenValue, 8, 0); return parseIdentifier(parser, context); } function parseImportDeclaration(parser, context, scope) { const start = parser.tokenStart; nextToken(parser, context); let source = null; const { tokenStart } = parser; let specifiers = []; if (parser.getToken() === 134283267) { source = parseLiteral(parser, context); } else { if (parser.getToken() & 143360) { const local = parseRestrictedIdentifier(parser, context, scope); specifiers = [ parser.finishNode({ type: 'ImportDefaultSpecifier', local, }, tokenStart), ]; if (consumeOpt(parser, context, 18)) { switch (parser.getToken()) { case 8391476: specifiers.push(parseImportNamespaceSpecifier(parser, context, scope)); break; case 2162700: parseImportSpecifierOrNamedImports(parser, context, scope, specifiers); break; default: parser.report(107); } } } else { switch (parser.getToken()) { case 8391476: specifiers = [parseImportNamespaceSpecifier(parser, context, scope)]; break; case 2162700: parseImportSpecifierOrNamedImports(parser, context, scope, specifiers); break; case 67174411: return parseImportCallDeclaration(parser, context, undefined, start); case 67108877: return parseImportMetaDeclaration(parser, context, start); default: parser.report(30, KeywordDescTable[parser.getToken() & 255]); } } source = parseModuleSpecifier(parser, context); } const attributes = parseImportAttributes(parser, context); const node = { type: 'ImportDeclaration', specifiers, source, attributes, }; matchOrInsertSemicolon(parser, context | 32); return parser.finishNode(node, start); } function parseImportNamespaceSpecifier(parser, context, scope) { const { tokenStart } = parser; nextToken(parser, context); consume(parser, context, 77932); if ((parser.getToken() & 134217728) === 134217728) { throw new ParseError(tokenStart, parser.currentLocation, 30, KeywordDescTable[parser.getToken() & 255]); } return parser.finishNode({ type: 'ImportNamespaceSpecifier', local: parseRestrictedIdentifier(parser, context, scope), }, tokenStart); } function parseModuleSpecifier(parser, context) { consume(parser, context, 209011); if (parser.getToken() !== 134283267) parser.report(105, 'Import'); return parseLiteral(parser, context); } function parseImportSpecifierOrNamedImports(parser, context, scope, specifiers) { nextToken(parser, context); while (parser.getToken() & 143360 || parser.getToken() === 134283267) { let { tokenValue, tokenStart } = parser; const token = parser.getToken(); const imported = parseModuleExportName(parser, context); let local; if (consumeOpt(parser, context, 77932)) { if ((parser.getToken() & 134217728) === 134217728 || parser.getToken() === 18) { parser.report(106); } else { validateBindingIdentifier(parser, context, 16, parser.getToken(), 0); } tokenValue = parser.tokenValue; local = parseIdentifier(parser, context); } else if (imported.type === 'Identifier') { validateBindingIdentifier(parser, context, 16, token, 0); local = imported; } else { parser.report(25, KeywordDescTable[77932 & 255]); } scope?.addBlockName(context, tokenValue, 8, 0); specifiers.push(parser.finishNode({ type: 'ImportSpecifier', local, imported, }, tokenStart)); if (parser.getToken() !== 1074790415) consume(parser, context, 18); } consume(parser, context, 1074790415); return specifiers; } function parseImportMetaDeclaration(parser, context, start) { let expr = parseImportMetaExpression(parser, context, parser.finishNode({ type: 'Identifier', name: 'import', }, start), start); expr = parseMemberOrUpdateExpression(parser, context, undefined, expr, 0, 0, start); expr = parseAssignmentExpression(parser, context, undefined, 0, 0, start, expr); if (parser.getToken() === 18) { expr = parseSequenceExpression(parser, context, undefined, 0, start, expr); } return parseExpressionStatement(parser, context, expr, start); } function parseImportCallDeclaration(parser, context, privateScope, start) { let expr = parseImportExpression(parser, context, privateScope, 0, start); expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, 0, 0, start); if (parser.getToken() === 18) { expr = parseSequenceExpression(parser, context, privateScope, 0, start, expr); } return parseExpressionStatement(parser, context, expr, start); } function parseExportDeclaration(parser, context, scope) { const start = parser.leadingDecorators.decorators.length ? parser.leadingDecorators.start : parser.tokenStart; nextToken(parser, context | 32); const specifiers = []; let declaration = null; let source = null; let attributes = []; if (consumeOpt(parser, context | 32, 20561)) { switch (parser.getToken()) { case 86104: { declaration = parseFunctionDeclaration(parser, context, scope, undefined, 4, 1, 1, 0, parser.tokenStart); break; } case 132: case 86094: declaration = parseClassDeclaration(parser, context, scope, undefined, 1); break; case 209005: { const { tokenStart } = parser; declaration = parseIdentifier(parser, context); const { flags } = parser; if ((flags & 1) === 0) { if (parser.getToken() === 86104) { declaration = parseFunctionDeclaration(parser, context, scope, undefined, 4, 1, 1, 1, tokenStart); } else { if (parser.getToken() === 67174411) { declaration = parseAsyncArrowOrCallExpression(parser, context, undefined, declaration, 1, 1, 0, flags, tokenStart); declaration = parseMemberOrUpdateExpression(parser, context, undefined, declaration, 0, 0, tokenStart); declaration = parseAssignmentExpression(parser, context, undefined, 0, 0, tokenStart, declaration); } else if (parser.getToken() & 143360) { if (scope) scope = createArrowHeadParsingScope(parser, context, parser.tokenValue); declaration = parseIdentifier(parser, context); declaration = parseArrowFunctionExpression(parser, context, scope, undefined, [declaration], 1, tokenStart); } } } break; } default: declaration = parseExpression(parser, context, undefined, 1, 0, parser.tokenStart); matchOrInsertSemicolon(parser, context | 32); } if (scope) parser.declareUnboundVariable('default'); return parser.finishNode({ type: 'ExportDefaultDeclaration', declaration, }, start); } switch (parser.getToken()) { case 8391476: { nextToken(parser, context); let exported = null; const isNamedDeclaration = consumeOpt(parser, context, 77932); if (isNamedDeclaration) { if (scope) parser.declareUnboundVariable(parser.tokenValue); exported = parseModuleExportName(parser, context); } consume(parser, context, 209011); if (parser.getToken() !== 134283267) parser.report(105, 'Export'); source = parseLiteral(parser, context); const attributes = parseImportAttributes(parser, context); const node = { type: 'ExportAllDeclaration', source, exported, attributes, }; matchOrInsertSemicolon(parser, context | 32); return parser.finishNode(node, start); } case 2162700: { nextToken(parser, context); const tmpExportedNames = []; const tmpExportedBindings = []; let hasLiteralLocal = 0; while (parser.getToken() & 143360 || parser.getToken() === 134283267) { const { tokenStart, tokenValue } = parser; const local = parseModuleExportName(parser, context); if (local.type === 'Literal') { hasLiteralLocal = 1; } let exported; if (parser.getToken() === 77932) { nextToken(parser, context); if ((parser.getToken() & 143360) === 0 && parser.getToken() !== 134283267) { parser.report(106); } if (scope) { tmpExportedNames.push(parser.tokenValue); tmpExportedBindings.push(tokenValue); } exported = parseModuleExportName(parser, context); } else { if (scope) { tmpExportedNames.push(parser.tokenValue); tmpExportedBindings.push(parser.tokenValue); } exported = local; } specifiers.push(parser.finishNode({ type: 'ExportSpecifier', local, exported, }, tokenStart)); if (parser.getToken() !== 1074790415) consume(parser, context, 18); } consume(parser, context, 1074790415); if (consumeOpt(parser, context, 209011)) { if (parser.getToken() !== 134283267) parser.report(105, 'Export'); source = parseLiteral(parser, context); attributes = parseImportAttributes(parser, context); if (scope) { tmpExportedNames.forEach((n) => parser.declareUnboundVariable(n)); } } else { if (hasLiteralLocal) { parser.report(172); } if (scope) { tmpExportedNames.forEach((n) => parser.declareUnboundVariable(n)); tmpExportedBindings.forEach((b) => parser.addBindingToExports(b)); } } matchOrInsertSemicolon(parser, context | 32); break; } case 132: case 86094: declaration = parseClassDeclaration(parser, context, scope, undefined, 2); break; case 86104: declaration = parseFunctionDeclaration(parser, context, scope, undefined, 4, 1, 2, 0, parser.tokenStart); break; case 241737: declaration = parseLexicalDeclaration(parser, context, scope, undefined, 8, 64); break; case 86090: declaration = parseLexicalDeclaration(parser, context, scope, undefined, 16, 64); break; case 86088: declaration = parseVariableStatement(parser, context, scope, undefined, 64); break; case 209005: { const { tokenStart } = parser; nextToken(parser, context); if ((parser.flags & 1) === 0 && parser.getToken() === 86104) { declaration = parseFunctionDeclaration(parser, context, scope, undefined, 4, 1, 2, 1, tokenStart); break; } } default: parser.report(30, KeywordDescTable[parser.getToken() & 255]); } const node = { type: 'ExportNamedDeclaration', declaration, specifiers, source, attributes: attributes, }; return parser.finishNode(node, start); } function parseExpression(parser, context, privateScope, canAssign, inGroup, start) { let expr = parsePrimaryExpression(parser, context, privateScope, 2, 0, canAssign, inGroup, 1, start); expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, inGroup, 0, start); return parseAssignmentExpression(parser, context, privateScope, inGroup, 0, start, expr); } function parseSequenceExpression(parser, context, privateScope, inGroup, start, expr) { const expressions = [expr]; while (consumeOpt(parser, context | 32, 18)) { expressions.push(parseExpression(parser, context, privateScope, 1, inGroup, parser.tokenStart)); } return parser.finishNode({ type: 'SequenceExpression', expressions, }, start); } function parseExpressions(parser, context, privateScope, inGroup, canAssign, start) { const expr = parseExpression(parser, context, privateScope, canAssign, inGroup, start); return parser.getToken() === 18 ? parseSequenceExpression(parser, context, privateScope, inGroup, start, expr) : expr; } function parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, start, left) { const token = parser.getToken(); if ((token & 4194304) === 4194304) { if (parser.assignable & 2) parser.report(26); if ((!isPattern && token === 1077936155 && left.type === 'ArrayExpression') || left.type === 'ObjectExpression') { reinterpretToPattern(parser, left); } nextToken(parser, context | 32); const right = parseExpression(parser, context, privateScope, 1, inGroup, parser.tokenStart); parser.assignable = 2; return parser.finishNode(isPattern ? { type: 'AssignmentPattern', left, right, } : { type: 'AssignmentExpression', left, operator: KeywordDescTable[token & 255], right, }, start); } if ((token & 8388608) === 8388608) { left = parseBinaryExpression(parser, context, privateScope, inGroup, start, 4, token, left); } if (consumeOpt(parser, context | 32, 22)) { left = parseConditionalExpression(parser, context, privateScope, left, start); } return left; } function parseAssignmentExpressionOrPattern(parser, context, privateScope, inGroup, isPattern, start, left) { const token = parser.getToken(); nextToken(parser, context | 32); const right = parseExpression(parser, context, privateScope, 1, inGroup, parser.tokenStart); left = parser.finishNode(isPattern ? { type: 'AssignmentPattern', left, right, } : { type: 'AssignmentExpression', left, operator: KeywordDescTable[token & 255], right, }, start); parser.assignable = 2; return left; } function parseConditionalExpression(parser, context, privateScope, test, start) { const consequent = parseExpression(parser, (context | 131072) ^ 131072, privateScope, 1, 0, parser.tokenStart); consume(parser, context | 32, 21); parser.assignable = 1; const alternate = parseExpression(parser, context, privateScope, 1, 0, parser.tokenStart); parser.assignable = 2; return parser.finishNode({ type: 'ConditionalExpression', test, consequent, alternate, }, start); } function parseBinaryExpression(parser, context, privateScope, inGroup, start, minPrecedence, operator, left) { const bit = -((context & 131072) > 0) & 8673330; let t; let precedence; parser.assignable = 2; while (parser.getToken() & 8388608) { t = parser.getToken(); precedence = t & 3840; if ((t & 524288 && operator & 268435456) || (operator & 524288 && t & 268435456)) { parser.report(165); } if (precedence + ((t === 8391735) << 8) - ((bit === t) << 12) <= minPrecedence) break; nextToken(parser, context | 32); left = parser.finishNode({ type: t & 524288 || t & 268435456 ? 'LogicalExpression' : 'BinaryExpression', left, right: parseBinaryExpression(parser, context, privateScope, inGroup, parser.tokenStart, precedence, t, parseLeftHandSideExpression(parser, context, privateScope, 0, inGroup, 1)), operator: KeywordDescTable[t & 255], }, start); } if (parser.getToken() === 1077936155) parser.report(26); return left; } function parseUnaryExpression(parser, context, privateScope, isLHS, inGroup) { if (!isLHS) parser.report(0); const { tokenStart } = parser; const unaryOperator = parser.getToken(); nextToken(parser, context | 32); const arg = parseLeftHandSideExpression(parser, context, privateScope, 0, inGroup, 1); if (parser.getToken() === 8391735) parser.report(33); if (context & 1 && unaryOperator === 16863276) { if (arg.type === 'Identifier') { parser.report(121); } else if (isPropertyWithPrivateFieldKey(arg)) { parser.report(127); } } parser.assignable = 2; return parser.finishNode({ type: 'UnaryExpression', operator: KeywordDescTable[unaryOperator & 255], argument: arg, prefix: true, }, tokenStart); } function parseAsyncExpression(parser, context, privateScope, inGroup, isLHS, canAssign, inNew, start) { const token = parser.getToken(); const expr = parseIdentifier(parser, context); const { flags } = parser; if ((flags & 1) === 0) { if (parser.getToken() === 86104) { return parseFunctionExpression(parser, context, privateScope, 1, inGroup, start); } if (isValidIdentifier(context, parser.getToken())) { if (!isLHS) parser.report(0); if ((parser.getToken() & 36864) === 36864) { parser.flags |= 256; } return parseAsyncArrowAfterIdent(parser, context, privateScope, canAssign, start); } } if (!inNew && parser.getToken() === 67174411) { return parseAsyncArrowOrCallExpression(parser, context, privateScope, expr, canAssign, 1, 0, flags, start); } if (parser.getToken() === 10) { classifyIdentifier(parser, context, token); if (inNew) parser.report(51); if ((token & 36864) === 36864) { parser.flags |= 256; } return parseArrowFromIdentifier(parser, context, privateScope, parser.tokenValue, expr, inNew, canAssign, 0, start); } parser.assignable = 1; return expr; } function parseYieldExpressionOrIdentifier(parser, context, privateScope, inGroup, canAssign, start) { if (inGroup) parser.destructible |= 256; if (context & 1024) { nextToken(parser, context | 32); if (context & 8192) parser.report(32); if (!canAssign) parser.report(26); if (parser.getToken() === 22) parser.report(124); let argument = null; let delegate = false; if ((parser.flags & 1) === 0) { delegate = consumeOpt(parser, context | 32, 8391476); if (parser.getToken() & (12288 | 65536) || delegate) { argument = parseExpression(parser, context, privateScope, 1, 0, parser.tokenStart); } } else if (parser.getToken() === 8391476) { parser.report(30, KeywordDescTable[parser.getToken() & 255]); } parser.assignable = 2; return parser.finishNode({ type: 'YieldExpression', argument, delegate, }, start); } if (context & 1) parser.report(97, 'yield'); return parseIdentifierOrArrow(parser, context, privateScope); } function parseAwaitExpressionOrIdentifier(parser, context, privateScope, inNew, inGroup, start) { if (inGroup) parser.destructible |= 128; if (context & 524288) parser.report(177); const possibleIdentifierOrArrowFunc = parseIdentifierOrArrow(parser, context, privateScope); const isIdentifier = possibleIdentifierOrArrowFunc.type === 'ArrowFunctionExpression' || (parser.getToken() & 65536) === 0; if (isIdentifier) { if (context & 2048) throw new ParseError(start, { index: parser.startIndex, line: parser.startLine, column: parser.startColumn }, 176); if (context & 2) throw new ParseError(start, { index: parser.startIndex, line: parser.startLine, column: parser.startColumn }, 110); if (context & 8192 && context & 2048) throw new ParseError(start, { index: parser.startIndex, line: parser.startLine, column: parser.startColumn }, 110); return possibleIdentifierOrArrowFunc; } if (context & 8192) { throw new ParseError(start, { index: parser.startIndex, line: parser.startLine, column: parser.startColumn }, 31); } if (context & 2048 || (context & 2 && context & 8)) { if (inNew) throw new ParseError(start, { index: parser.startIndex, line: parser.startLine, column: parser.startColumn }, 0); const argument = parseLeftHandSideExpression(parser, context, privateScope, 0, 0, 1); if (parser.getToken() === 8391735) parser.report(33); parser.assignable = 2; return parser.finishNode({ type: 'AwaitExpression', argument, }, start); } if (context & 2) throw new ParseError(start, { index: parser.startIndex, line: parser.startLine, column: parser.startColumn }, 98); return possibleIdentifierOrArrowFunc; } function parseFunctionBody(parser, context, scope, privateScope, origin, funcNameToken, functionScope) { const { tokenStart } = parser; consume(parser, context | 32, 2162700); const body = []; if (parser.getToken() !== 1074790415) { while (parser.getToken() === 134283267) { const { index, tokenStart, tokenIndex, tokenValue } = parser; const token = parser.getToken(); const expr = parseLiteral(parser, context); if (isValidStrictMode(parser, index, tokenIndex, tokenValue)) { context |= 1; if (parser.flags & 128) { throw new ParseError(tokenStart, parser.currentLocation, 66); } if (parser.flags & 64) { throw new ParseError(tokenStart, parser.currentLocation, 9); } if (parser.flags & 4096) { throw new ParseError(tokenStart, parser.currentLocation, 15); } functionScope?.reportScopeError(); } body.push(parseDirective(parser, context, expr, token, tokenStart)); } if (context & 1) { if (funcNameToken) { if ((funcNameToken & 537079808) === 537079808) { parser.report(119); } if ((funcNameToken & 36864) === 36864) { parser.report(40); } } if (parser.flags & 512) parser.report(119); if (parser.flags & 256) parser.report(118); } } parser.flags = (parser.flags | 512 | 256 | 64 | 4096) ^ (512 | 256 | 64 | 4096); parser.destructible = (parser.destructible | 256) ^ 256; while (parser.getToken() !== 1074790415) { body.push(parseStatementListItem(parser, context, scope, privateScope, 4, {})); } consume(parser, origin & (16 | 8) ? context | 32 : context, 1074790415); parser.flags &= -4289; if (parser.getToken() === 1077936155) parser.report(26); return parser.finishNode({ type: 'BlockStatement', body, }, tokenStart); } function parseSuperExpression(parser, context) { const { tokenStart } = parser; nextToken(parser, context); switch (parser.getToken()) { case 67108990: parser.report(167); case 67174411: { if ((context & 512) === 0) parser.report(28); parser.assignable = 2; break; } case 69271571: case 67108877: { if ((context & 256) === 0) parser.report(29); parser.assignable = 1; break; } default: parser.report(30, 'super'); } return parser.finishNode({ type: 'Super' }, tokenStart); } function parseLeftHandSideExpression(parser, context, privateScope, canAssign, inGroup, isLHS) { const start = parser.tokenStart; const expression = parsePrimaryExpression(parser, context, privateScope, 2, 0, canAssign, inGroup, isLHS, start); return parseMemberOrUpdateExpression(parser, context, privateScope, expression, inGroup, 0, start); } function parseUpdateExpression(parser, context, expr, start) { if (parser.assignable & 2) parser.report(55); const token = parser.getToken(); nextToken(parser, context); parser.assignable = 2; return parser.finishNode({ type: 'UpdateExpression', argument: expr, operator: KeywordDescTable[token & 255], prefix: false, }, start); } function parseMemberOrUpdateExpression(parser, context, privateScope, expr, inGroup, inChain, start) { if ((parser.getToken() & 33619968) === 33619968 && (parser.flags & 1) === 0) { expr = parseUpdateExpression(parser, context, expr, start); } else if ((parser.getToken() & 67108864) === 67108864) { context = (context | 131072) ^ 131072; switch (parser.getToken()) { case 67108877: { nextToken(parser, (context | 262144 | 8) ^ 8); if (context & 16 && parser.getToken() === 130 && parser.tokenValue === 'super') { parser.report(173); } parser.assignable = 1; const property = parsePropertyOrPrivatePropertyName(parser, context | 64, privateScope); expr = parser.finishNode({ type: 'MemberExpression', object: expr, computed: false, property, optional: false, }, start); break; } case 69271571: { let restoreHasOptionalChaining = false; if ((parser.flags & 2048) === 2048) { restoreHasOptionalChaining = true; parser.flags = (parser.flags | 2048) ^ 2048; } nextToken(parser, context | 32); const { tokenStart } = parser; const property = parseExpressions(parser, context, privateScope, inGroup, 1, tokenStart); consume(parser, context, 20); parser.assignable = 1; expr = parser.finishNode({ type: 'MemberExpression', object: expr, computed: true, property, optional: false, }, start); if (restoreHasOptionalChaining) { parser.flags |= 2048; } break; } case 67174411: { if ((parser.flags & 1024) === 1024) { parser.flags = (parser.flags | 1024) ^ 1024; return expr; } let restoreHasOptionalChaining = false; if ((parser.flags & 2048) === 2048) { restoreHasOptionalChaining = true; parser.flags = (parser.flags | 2048) ^ 2048; } const args = parseArguments(parser, context, privateScope, inGroup); parser.assignable = 2; expr = parser.finishNode({ type: 'CallExpression', callee: expr, arguments: args, optional: false, }, start); if (restoreHasOptionalChaining) { parser.flags |= 2048; } break; } case 67108990: { nextToken(parser, (context | 262144 | 8) ^ 8); parser.flags |= 2048; parser.assignable = 2; expr = parseOptionalChain(parser, context, privateScope, expr, start); break; } default: if ((parser.flags & 2048) === 2048) { parser.report(166); } parser.assignable = 2; expr = parser.finishNode({ type: 'TaggedTemplateExpression', tag: expr, quasi: parser.getToken() === 67174408 ? parseTemplate(parser, context | 64, privateScope) : parseTemplateLiteral(parser, context), }, start); } expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, 0, 1, start); } if (inChain === 0 && (parser.flags & 2048) === 2048) { parser.flags = (parser.flags | 2048) ^ 2048; expr = parser.finishNode({ type: 'ChainExpression', expression: expr, }, start); } return expr; } function parseOptionalChain(parser, context, privateScope, expr, start) { let restoreHasOptionalChaining = false; let node; if (parser.getToken() === 69271571 || parser.getToken() === 67174411) { if ((parser.flags & 2048) === 2048) { restoreHasOptionalChaining = true; parser.flags = (parser.flags | 2048) ^ 2048; } } if (parser.getToken() === 69271571) { nextToken(parser, context | 32); const { tokenStart } = parser; const property = parseExpressions(parser, context, privateScope, 0, 1, tokenStart); consume(parser, context, 20); parser.assignable = 2; node = parser.finishNode({ type: 'MemberExpression', object: expr, computed: true, optional: true, property, }, start); } else if (parser.getToken() === 67174411) { const args = parseArguments(parser, context, privateScope, 0); parser.assignable = 2; node = parser.finishNode({ type: 'CallExpression', callee: expr, arguments: args, optional: true, }, start); } else { const property = parsePropertyOrPrivatePropertyName(parser, context, privateScope); parser.assignable = 2; node = parser.finishNode({ type: 'MemberExpression', object: expr, computed: false, optional: true, property, }, start); } if (restoreHasOptionalChaining) { parser.flags |= 2048; } return node; } function parsePropertyOrPrivatePropertyName(parser, context, privateScope) { if ((parser.getToken() & 143360) === 0 && parser.getToken() !== -2147483528 && parser.getToken() !== -2147483527 && parser.getToken() !== 130) { parser.report(160); } return parser.getToken() === 130 ? parsePrivateIdentifier(parser, context, privateScope, 0) : parseIdentifier(parser, context); } function parseUpdateExpressionPrefixed(parser, context, privateScope, inNew, isLHS, start) { if (inNew) parser.report(56); if (!isLHS) parser.report(0); const token = parser.getToken(); nextToken(parser, context | 32); const arg = parseLeftHandSideExpression(parser, context, privateScope, 0, 0, 1); if (parser.assignable & 2) { parser.report(55); } parser.assignable = 2; return parser.finishNode({ type: 'UpdateExpression', argument: arg, operator: KeywordDescTable[token & 255], prefix: true, }, start); } function parsePrimaryExpression(parser, context, privateScope, kind, inNew, canAssign, inGroup, isLHS, start) { if ((parser.getToken() & 143360) === 143360) { switch (parser.getToken()) { case 209006: return parseAwaitExpressionOrIdentifier(parser, context, privateScope, inNew, inGroup, start); case 241771: return parseYieldExpressionOrIdentifier(parser, context, privateScope, inGroup, canAssign, start); case 209005: return parseAsyncExpression(parser, context, privateScope, inGroup, isLHS, canAssign, inNew, start); } const { tokenValue } = parser; const token = parser.getToken(); const expr = parseIdentifier(parser, context | 64); if (parser.getToken() === 10) { if (!isLHS) parser.report(0); classifyIdentifier(parser, context, token); if ((token & 36864) === 36864) { parser.flags |= 256; } return parseArrowFromIdentifier(parser, context, privateScope, tokenValue, expr, inNew, canAssign, 0, start); } if (context & 16 && !(context & 32768) && !(context & 8192) && parser.tokenValue === 'arguments') parser.report(130); if ((token & 255) === (241737 & 255)) { if (context & 1) parser.report(113); if (kind & (8 | 16)) parser.report(100); } parser.assignable = context & 1 && (token & 537079808) === 537079808 ? 2 : 1; return expr; } if ((parser.getToken() & 134217728) === 134217728) { return parseLiteral(parser, context); } switch (parser.getToken()) { case 33619993: case 33619994: return parseUpdateExpressionPrefixed(parser, context, privateScope, inNew, isLHS, start); case 16863276: case 16842798: case 16842799: case 25233968: case 25233969: case 16863275: case 16863277: return parseUnaryExpression(parser, context, privateScope, isLHS, inGroup); case 86104: return parseFunctionExpression(parser, context, privateScope, 0, inGroup, start); case 2162700: return parseObjectLiteral(parser, context, privateScope, canAssign ? 0 : 1, inGroup); case 69271571: return parseArrayLiteral(parser, context, privateScope, canAssign ? 0 : 1, inGroup); case 67174411: return parseParenthesizedExpression(parser, context | 64, privateScope, canAssign, 1, 0, start); case 86021: case 86022: case 86023: return parseNullOrTrueOrFalseLiteral(parser, context); case 86111: return parseThisExpression(parser, context); case 65540: return parseRegExpLiteral(parser, context); case 132: case 86094: return parseClassExpression(parser, context, privateScope, inGroup, start); case 86109: return parseSuperExpression(parser, context); case 67174409: return parseTemplateLiteral(parser, context); case 67174408: return parseTemplate(parser, context, privateScope); case 86107: return parseNewExpression(parser, context, privateScope, inGroup); case 134283388: return parseBigIntLiteral(parser, context); case 130: return parsePrivateIdentifier(parser, context, privateScope, 0); case 86106: return parseImportCallOrMetaExpression(parser, context, privateScope, inNew, inGroup, start); case 8456256: if (parser.options.jsx) return parseJSXRootElementOrFragment(parser, context, privateScope, 0, parser.tokenStart); default: if (isValidIdentifier(context, parser.getToken())) return parseIdentifierOrArrow(parser, context, privateScope); parser.report(30, KeywordDescTable[parser.getToken() & 255]); } } function parseImportCallOrMetaExpression(parser, context, privateScope, inNew, inGroup, start) { let expr = parseIdentifier(parser, context); if (parser.getToken() === 67108877) { return parseImportMetaExpression(parser, context, expr, start); } if (inNew) parser.report(142); expr = parseImportExpression(parser, context, privateScope, inGroup, start); parser.assignable = 2; return parseMemberOrUpdateExpression(parser, context, privateScope, expr, inGroup, 0, start); } function parseImportMetaExpression(parser, context, meta, start) { if ((context & 2) === 0) parser.report(169); nextToken(parser, context); const token = parser.getToken(); if (token !== 209030 && parser.tokenValue !== 'meta') { parser.report(174); } else if (token & -2147483648) { parser.report(175); } parser.assignable = 2; return parser.finishNode({ type: 'MetaProperty', meta, property: parseIdentifier(parser, context), }, start); } function parseImportExpression(parser, context, privateScope, inGroup, start) { consume(parser, context | 32, 67174411); if (parser.getToken() === 14) parser.report(143); const source = parseExpression(parser, context, privateScope, 1, inGroup, parser.tokenStart); let options = null; if (parser.getToken() === 18) { consume(parser, context, 18); if (parser.getToken() !== 16) { const expContext = (context | 131072) ^ 131072; options = parseExpression(parser, expContext, privateScope, 1, inGroup, parser.tokenStart); } consumeOpt(parser, context, 18); } const node = { type: 'ImportExpression', source, options, }; consume(parser, context, 16); return parser.finishNode(node, start); } function parseImportAttributes(parser, context) { if (!consumeOpt(parser, context, 20579)) return []; consume(parser, context, 2162700); const attributes = []; const keysContent = new Set(); while (parser.getToken() !== 1074790415) { const start = parser.tokenStart; const key = parseIdentifierOrStringLiteral(parser, context); consume(parser, context, 21); const value = parseStringLiteral(parser, context); const keyContent = key.type === 'Literal' ? key.value : key.name; if (keysContent.has(keyContent)) { parser.report(145, `${keyContent}`); } keysContent.add(keyContent); attributes.push(parser.finishNode({ type: 'ImportAttribute', key, value, }, start)); if (parser.getToken() !== 1074790415) { consume(parser, context, 18); } } consume(parser, context, 1074790415); return attributes; } function parseStringLiteral(parser, context) { if (parser.getToken() === 134283267) { return parseLiteral(parser, context); } else { parser.report(30, KeywordDescTable[parser.getToken() & 255]); } } function parseIdentifierOrStringLiteral(parser, context) { if (parser.getToken() === 134283267) { return parseLiteral(parser, context); } else if (parser.getToken() & 143360) { return parseIdentifier(parser, context); } else { parser.report(30, KeywordDescTable[parser.getToken() & 255]); } } function validateStringWellFormed(parser, str) { const len = str.length; for (let i = 0; i < len; i++) { const code = str.charCodeAt(i); if ((code & 0xfc00) !== 55296) continue; if (code > 56319 || ++i >= len || (str.charCodeAt(i) & 0xfc00) !== 56320) { parser.report(171, JSON.stringify(str.charAt(i--))); } } } function parseModuleExportName(parser, context) { if (parser.getToken() === 134283267) { validateStringWellFormed(parser, parser.tokenValue); return parseLiteral(parser, context); } else if (parser.getToken() & 143360) { return parseIdentifier(parser, context); } else { parser.report(30, KeywordDescTable[parser.getToken() & 255]); } } function parseBigIntLiteral(parser, context) { const { tokenRaw, tokenValue, tokenStart } = parser; nextToken(parser, context); parser.assignable = 2; const node = { type: 'Literal', value: tokenValue, bigint: String(tokenValue), }; if (parser.options.raw) { node.raw = tokenRaw; } return parser.finishNode(node, tokenStart); } function parseTemplateLiteral(parser, context) { parser.assignable = 2; const { tokenValue, tokenRaw, tokenStart } = parser; consume(parser, context, 67174409); const quasis = [parseTemplateElement(parser, tokenValue, tokenRaw, tokenStart, true)]; return parser.finishNode({ type: 'TemplateLiteral', expressions: [], quasis, }, tokenStart); } function parseTemplate(parser, context, privateScope) { context = (context | 131072) ^ 131072; const { tokenValue, tokenRaw, tokenStart } = parser; consume(parser, (context & -65) | 32, 67174408); const quasis = [parseTemplateElement(parser, tokenValue, tokenRaw, tokenStart, false)]; const expressions = [ parseExpressions(parser, context & -65, privateScope, 0, 1, parser.tokenStart), ]; if (parser.getToken() !== 1074790415) parser.report(83); while (parser.setToken(scanTemplateTail(parser, context), true) !== 67174409) { const { tokenValue, tokenRaw, tokenStart } = parser; consume(parser, (context & -65) | 32, 67174408); quasis.push(parseTemplateElement(parser, tokenValue, tokenRaw, tokenStart, false)); expressions.push(parseExpressions(parser, context, privateScope, 0, 1, parser.tokenStart)); if (parser.getToken() !== 1074790415) parser.report(83); } { const { tokenValue, tokenRaw, tokenStart } = parser; consume(parser, context, 67174409); quasis.push(parseTemplateElement(parser, tokenValue, tokenRaw, tokenStart, true)); } return parser.finishNode({ type: 'TemplateLiteral', expressions, quasis, }, tokenStart); } function parseTemplateElement(parser, cooked, raw, start, tail) { const node = parser.finishNode({ type: 'TemplateElement', value: { cooked, raw, }, tail, }, start); const tailSize = tail ? 1 : 2; if (parser.options.ranges) { node.start += 1; node.range[0] += 1; node.end -= tailSize; node.range[1] -= tailSize; } if (parser.options.loc) { node.loc.start.column += 1; node.loc.end.column -= tailSize; } return node; } function parseSpreadElement(parser, context, privateScope) { const start = parser.tokenStart; context = (context | 131072) ^ 131072; consume(parser, context | 32, 14); const argument = parseExpression(parser, context, privateScope, 1, 0, parser.tokenStart); parser.assignable = 1; return parser.finishNode({ type: 'SpreadElement', argument, }, start); } function parseArguments(parser, context, privateScope, inGroup) { nextToken(parser, context | 32); const args = []; if (parser.getToken() === 16) { nextToken(parser, context | 64); return args; } while (parser.getToken() !== 16) { if (parser.getToken() === 14) { args.push(parseSpreadElement(parser, context, privateScope)); } else { args.push(parseExpression(parser, context, privateScope, 1, inGroup, parser.tokenStart)); } if (parser.getToken() !== 18) break; nextToken(parser, context | 32); if (parser.getToken() === 16) break; } consume(parser, context | 64, 16); return args; } function parseIdentifier(parser, context) { const { tokenValue, tokenStart } = parser; const allowRegex = tokenValue === 'await' && (parser.getToken() & -2147483648) === 0; nextToken(parser, context | (allowRegex ? 32 : 0)); return parser.finishNode({ type: 'Identifier', name: tokenValue, }, tokenStart); } function parseLiteral(parser, context) { const { tokenValue, tokenRaw, tokenStart } = parser; if (parser.getToken() === 134283388) { return parseBigIntLiteral(parser, context); } nextToken(parser, context); parser.assignable = 2; return parser.finishNode(parser.options.raw ? { type: 'Literal', value: tokenValue, raw: tokenRaw, } : { type: 'Literal', value: tokenValue, }, tokenStart); } function parseNullOrTrueOrFalseLiteral(parser, context) { const start = parser.tokenStart; const raw = KeywordDescTable[parser.getToken() & 255]; const value = parser.getToken() === 86023 ? null : raw === 'true'; nextToken(parser, context); parser.assignable = 2; return parser.finishNode(parser.options.raw ? { type: 'Literal', value, raw, } : { type: 'Literal', value, }, start); } function parseThisExpression(parser, context) { const { tokenStart } = parser; nextToken(parser, context); parser.assignable = 2; return parser.finishNode({ type: 'ThisExpression', }, tokenStart); } function parseFunctionDeclaration(parser, context, scope, privateScope, origin, allowGen, flags, isAsync, start) { nextToken(parser, context | 32); const isGenerator = allowGen ? optionalBit(parser, context, 8391476) : 0; let id = null; let funcNameToken; let functionScope = scope ? parser.createScope() : void 0; if (parser.getToken() === 67174411) { if ((flags & 1) === 0) parser.report(39, 'Function'); } else { const kind = origin & 4 && ((context & 8) === 0 || (context & 2) === 0) ? 4 : 64 | (isAsync ? 1024 : 0) | (isGenerator ? 1024 : 0); validateFunctionName(parser, context, parser.getToken()); if (scope) { if (kind & 4) { scope.addVarName(context, parser.tokenValue, kind); } else { scope.addBlockName(context, parser.tokenValue, kind, origin); } functionScope = functionScope?.createChildScope(128); if (flags) { if (flags & 2) { parser.declareUnboundVariable(parser.tokenValue); } } } funcNameToken = parser.getToken(); if (parser.getToken() & 143360) { id = parseIdentifier(parser, context); } else { parser.report(30, KeywordDescTable[parser.getToken() & 255]); } } { const modifierFlags = 256 | 512 | 1024 | 2048 | 8192 | 16384; context = ((context | modifierFlags) ^ modifierFlags) | 65536 | (isAsync ? 2048 : 0) | (isGenerator ? 1024 : 0) | (isGenerator ? 0 : 262144); } functionScope = functionScope?.createChildScope(256); const params = parseFormalParametersOrFormalList(parser, (context | 8192) & -524289, functionScope, privateScope, 0, 1); const modifierFlags = 8 | 4 | 128 | 524288; const body = parseFunctionBody(parser, ((context | modifierFlags) ^ modifierFlags) | 32768 | 4096, functionScope?.createChildScope(64), privateScope, 8, funcNameToken, functionScope); return parser.finishNode({ type: 'FunctionDeclaration', id, params, body, async: isAsync === 1, generator: isGenerator === 1, }, start); } function parseFunctionExpression(parser, context, privateScope, isAsync, inGroup, start) { nextToken(parser, context | 32); const isGenerator = optionalBit(parser, context, 8391476); const generatorAndAsyncFlags = (isAsync ? 2048 : 0) | (isGenerator ? 1024 : 0); let id = null; let funcNameToken; let scope = parser.createScopeIfLexical(); const modifierFlags = 256 | 512 | 1024 | 2048 | 8192 | 16384 | 524288; if (parser.getToken() & 143360) { validateFunctionName(parser, ((context | modifierFlags) ^ modifierFlags) | generatorAndAsyncFlags, parser.getToken()); scope = scope?.createChildScope(128); funcNameToken = parser.getToken(); id = parseIdentifier(parser, context); } context = ((context | modifierFlags) ^ modifierFlags) | 65536 | generatorAndAsyncFlags | (isGenerator ? 0 : 262144); scope = scope?.createChildScope(256); const params = parseFormalParametersOrFormalList(parser, (context | 8192) & -524289, scope, privateScope, inGroup, 1); const body = parseFunctionBody(parser, (context & -131229) | 32768 | 4096, scope?.createChildScope(64), privateScope, 0, funcNameToken, scope); parser.assignable = 2; return parser.finishNode({ type: 'FunctionExpression', id, params, body, async: isAsync === 1, generator: isGenerator === 1, }, start); } function parseArrayLiteral(parser, context, privateScope, skipInitializer, inGroup) { const expr = parseArrayExpressionOrPattern(parser, context, void 0, privateScope, skipInitializer, inGroup, 0, 2, 0); if (parser.destructible & 64) { parser.report(63); } if (parser.destructible & 8) { parser.report(62); } return expr; } function parseArrayExpressionOrPattern(parser, context, scope, privateScope, skipInitializer, inGroup, isPattern, kind, origin) { const { tokenStart: start } = parser; nextToken(parser, context | 32); const elements = []; let destructible = 0; context = (context | 131072) ^ 131072; while (parser.getToken() !== 20) { if (consumeOpt(parser, context | 32, 18)) { elements.push(null); } else { let left; const { tokenStart, tokenValue } = parser; const token = parser.getToken(); if (token & 143360) { left = parsePrimaryExpression(parser, context, privateScope, kind, 0, 1, inGroup, 1, tokenStart); if (parser.getToken() === 1077936155) { if (parser.assignable & 2) parser.report(26); nextToken(parser, context | 32); scope?.addVarOrBlock(context, tokenValue, kind, origin); const right = parseExpression(parser, context, privateScope, 1, inGroup, parser.tokenStart); left = parser.finishNode(isPattern ? { type: 'AssignmentPattern', left, right, } : { type: 'AssignmentExpression', operator: '=', left, right, }, tokenStart); destructible |= parser.destructible & 256 ? 256 : 0 | (parser.destructible & 128) ? 128 : 0; } else if (parser.getToken() === 18 || parser.getToken() === 20) { if (parser.assignable & 2) { destructible |= 16; } else { scope?.addVarOrBlock(context, tokenValue, kind, origin); } destructible |= parser.destructible & 256 ? 256 : 0 | (parser.destructible & 128) ? 128 : 0; } else { destructible |= kind & 1 ? 32 : (kind & 2) === 0 ? 16 : 0; left = parseMemberOrUpdateExpression(parser, context, privateScope, left, inGroup, 0, tokenStart); if (parser.getToken() !== 18 && parser.getToken() !== 20) { if (parser.getToken() !== 1077936155) destructible |= 16; left = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, left); } else if (parser.getToken() !== 1077936155) { destructible |= parser.assignable & 2 ? 16 : 32; } } } else if (token & 2097152) { left = parser.getToken() === 2162700 ? parseObjectLiteralOrPattern(parser, context, scope, privateScope, 0, inGroup, isPattern, kind, origin) : parseArrayExpressionOrPattern(parser, context, scope, privateScope, 0, inGroup, isPattern, kind, origin); destructible |= parser.destructible; parser.assignable = parser.destructible & 16 ? 2 : 1; if (parser.getToken() === 18 || parser.getToken() === 20) { if (parser.assignable & 2) { destructible |= 16; } } else if (parser.destructible & 8) { parser.report(71); } else { left = parseMemberOrUpdateExpression(parser, context, privateScope, left, inGroup, 0, tokenStart); destructible = parser.assignable & 2 ? 16 : 0; if (parser.getToken() !== 18 && parser.getToken() !== 20) { left = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, left); } else if (parser.getToken() !== 1077936155) { destructible |= parser.assignable & 2 ? 16 : 32; } } } else if (token === 14) { left = parseSpreadOrRestElement(parser, context, scope, privateScope, 20, kind, origin, 0, inGroup, isPattern); destructible |= parser.destructible; if (parser.getToken() !== 18 && parser.getToken() !== 20) parser.report(30, KeywordDescTable[parser.getToken() & 255]); } else { left = parseLeftHandSideExpression(parser, context, privateScope, 1, 0, 1); if (parser.getToken() !== 18 && parser.getToken() !== 20) { left = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, left); if ((kind & (2 | 1)) === 0 && token === 67174411) destructible |= 16; } else if (parser.assignable & 2) { destructible |= 16; } else if (token === 67174411) { destructible |= parser.assignable & 1 && kind & (2 | 1) ? 32 : 16; } } elements.push(left); if (consumeOpt(parser, context | 32, 18)) { if (parser.getToken() === 20) break; } else break; } } consume(parser, context, 20); const node = parser.finishNode({ type: isPattern ? 'ArrayPattern' : 'ArrayExpression', elements, }, start); if (!skipInitializer && parser.getToken() & 4194304) { return parseArrayOrObjectAssignmentPattern(parser, context, privateScope, destructible, inGroup, isPattern, start, node); } parser.destructible = destructible; return node; } function parseArrayOrObjectAssignmentPattern(parser, context, privateScope, destructible, inGroup, isPattern, start, node) { if (parser.getToken() !== 1077936155) parser.report(26); nextToken(parser, context | 32); if (destructible & 16) parser.report(26); if (!isPattern) reinterpretToPattern(parser, node); const { tokenStart } = parser; const right = parseExpression(parser, context, privateScope, 1, inGroup, tokenStart); parser.destructible = ((destructible | 64 | 8) ^ (8 | 64)) | (parser.destructible & 128 ? 128 : 0) | (parser.destructible & 256 ? 256 : 0); return parser.finishNode(isPattern ? { type: 'AssignmentPattern', left: node, right, } : { type: 'AssignmentExpression', left: node, operator: '=', right, }, start); } function parseSpreadOrRestElement(parser, context, scope, privateScope, closingToken, kind, origin, isAsync, inGroup, isPattern) { const { tokenStart: start } = parser; nextToken(parser, context | 32); let argument = null; let destructible = 0; const { tokenValue, tokenStart } = parser; let token = parser.getToken(); if (token & 143360) { parser.assignable = 1; argument = parsePrimaryExpression(parser, context, privateScope, kind, 0, 1, inGroup, 1, tokenStart); token = parser.getToken(); argument = parseMemberOrUpdateExpression(parser, context, privateScope, argument, inGroup, 0, tokenStart); if (parser.getToken() !== 18 && parser.getToken() !== closingToken) { if (parser.assignable & 2 && parser.getToken() === 1077936155) parser.report(71); destructible |= 16; argument = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, argument); } if (parser.assignable & 2) { destructible |= 16; } else if (token === closingToken || token === 18) { scope?.addVarOrBlock(context, tokenValue, kind, origin); } else { destructible |= 32; } destructible |= parser.destructible & 128 ? 128 : 0; } else if (token === closingToken) { parser.report(41); } else if (token & 2097152) { argument = parser.getToken() === 2162700 ? parseObjectLiteralOrPattern(parser, context, scope, privateScope, 1, inGroup, isPattern, kind, origin) : parseArrayExpressionOrPattern(parser, context, scope, privateScope, 1, inGroup, isPattern, kind, origin); token = parser.getToken(); if (token !== 1077936155 && token !== closingToken && token !== 18) { if (parser.destructible & 8) parser.report(71); argument = parseMemberOrUpdateExpression(parser, context, privateScope, argument, inGroup, 0, tokenStart); destructible |= parser.assignable & 2 ? 16 : 0; if ((parser.getToken() & 4194304) === 4194304) { if (parser.getToken() !== 1077936155) destructible |= 16; argument = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, argument); } else { if ((parser.getToken() & 8388608) === 8388608) { argument = parseBinaryExpression(parser, context, privateScope, 1, tokenStart, 4, token, argument); } if (consumeOpt(parser, context | 32, 22)) { argument = parseConditionalExpression(parser, context, privateScope, argument, tokenStart); } destructible |= parser.assignable & 2 ? 16 : 32; } } else { destructible |= closingToken === 1074790415 && token !== 1077936155 ? 16 : parser.destructible; } } else { destructible |= 32; argument = parseLeftHandSideExpression(parser, context, privateScope, 1, inGroup, 1); const { tokenStart } = parser; const token = parser.getToken(); if (token === 1077936155) { if (parser.assignable & 2) parser.report(26); argument = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, argument); destructible |= 16; } else { if (token === 18) { destructible |= 16; } else if (token !== closingToken) { argument = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, argument); } destructible |= parser.assignable & 1 ? 32 : 16; } parser.destructible = destructible; if (parser.getToken() !== closingToken && parser.getToken() !== 18) parser.report(161); return parser.finishNode({ type: isPattern ? 'RestElement' : 'SpreadElement', argument: argument, }, start); } if (parser.getToken() !== closingToken) { if (kind & 1) destructible |= isAsync ? 16 : 32; if (consumeOpt(parser, context | 32, 1077936155)) { if (destructible & 16) parser.report(26); reinterpretToPattern(parser, argument); const right = parseExpression(parser, context, privateScope, 1, inGroup, parser.tokenStart); argument = parser.finishNode(isPattern ? { type: 'AssignmentPattern', left: argument, right, } : { type: 'AssignmentExpression', left: argument, operator: '=', right, }, tokenStart); destructible = 16; } else { destructible |= 16; } } parser.destructible = destructible; return parser.finishNode({ type: isPattern ? 'RestElement' : 'SpreadElement', argument: argument, }, start); } function parseMethodDefinition(parser, context, privateScope, kind, inGroup, start) { const modifierFlags = 1024 | 2048 | 8192 | ((kind & 64) === 0 ? 512 | 16384 : 0); context = ((context | modifierFlags) ^ modifierFlags) | (kind & 8 ? 1024 : 0) | (kind & 16 ? 2048 : 0) | (kind & 64 ? 16384 : 0) | 256 | 32768 | 65536; let scope = parser.createScopeIfLexical(256); const params = parseMethodFormals(parser, (context | 8192) & -524289, scope, privateScope, kind, 1, inGroup); scope = scope?.createChildScope(64); const body = parseFunctionBody(parser, (context & -655373) | 32768 | 4096, scope, privateScope, 0, void 0, scope?.parent); return parser.finishNode({ type: 'FunctionExpression', params, body, async: (kind & 16) > 0, generator: (kind & 8) > 0, id: null, }, start); } function parseObjectLiteral(parser, context, privateScope, skipInitializer, inGroup) { const expr = parseObjectLiteralOrPattern(parser, context, void 0, privateScope, skipInitializer, inGroup, 0, 2, 0); if (parser.destructible & 64) { parser.report(63); } if (parser.destructible & 8) { parser.report(62); } return expr; } function parseObjectLiteralOrPattern(parser, context, scope, privateScope, skipInitializer, inGroup, isPattern, kind, origin) { const { tokenStart: start } = parser; nextToken(parser, context); const properties = []; let destructible = 0; let prototypeCount = 0; context = (context | 131072) ^ 131072; while (parser.getToken() !== 1074790415) { const { tokenValue, tokenStart } = parser; const token = parser.getToken(); if (token === 14) { properties.push(parseSpreadOrRestElement(parser, context, scope, privateScope, 1074790415, kind, origin, 0, inGroup, isPattern)); } else { let state = 0; let key = null; let value; if (parser.getToken() & 143360 || parser.getToken() === -2147483528 || parser.getToken() === -2147483527) { if (parser.getToken() === -2147483527) destructible |= 16; key = parseIdentifier(parser, context); if (parser.getToken() === 18 || parser.getToken() === 1074790415 || parser.getToken() === 1077936155) { state |= 4; if (context & 1 && (token & 537079808) === 537079808) { destructible |= 16; } else { validateBindingIdentifier(parser, context, kind, token, 0); } scope?.addVarOrBlock(context, tokenValue, kind, origin); if (consumeOpt(parser, context | 32, 1077936155)) { destructible |= 8; const right = parseExpression(parser, context, privateScope, 1, inGroup, parser.tokenStart); destructible |= parser.destructible & 256 ? 256 : 0 | (parser.destructible & 128) ? 128 : 0; value = parser.finishNode({ type: 'AssignmentPattern', left: parser.options.uniqueKeyInPattern ? Object.assign({}, key) : key, right, }, tokenStart); } else { destructible |= (token === 209006 ? 128 : 0) | (token === -2147483528 ? 16 : 0); value = parser.options.uniqueKeyInPattern ? Object.assign({}, key) : key; } } else if (consumeOpt(parser, context | 32, 21)) { const { tokenStart } = parser; if (tokenValue === '__proto__') prototypeCount++; if (parser.getToken() & 143360) { const tokenAfterColon = parser.getToken(); const valueAfterColon = parser.tokenValue; value = parsePrimaryExpression(parser, context, privateScope, kind, 0, 1, inGroup, 1, tokenStart); const token = parser.getToken(); value = parseMemberOrUpdateExpression(parser, context, privateScope, value, inGroup, 0, tokenStart); if (parser.getToken() === 18 || parser.getToken() === 1074790415) { if (token === 1077936155 || token === 1074790415 || token === 18) { destructible |= parser.destructible & 128 ? 128 : 0; if (parser.assignable & 2) { destructible |= 16; } else if ((tokenAfterColon & 143360) === 143360) { scope?.addVarOrBlock(context, valueAfterColon, kind, origin); } } else { destructible |= parser.assignable & 1 ? 32 : 16; } } else if ((parser.getToken() & 4194304) === 4194304) { if (parser.assignable & 2) { destructible |= 16; } else if (token !== 1077936155) { destructible |= 32; } else { scope?.addVarOrBlock(context, valueAfterColon, kind, origin); } value = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } else { destructible |= 16; if ((parser.getToken() & 8388608) === 8388608) { value = parseBinaryExpression(parser, context, privateScope, 1, tokenStart, 4, token, value); } if (consumeOpt(parser, context | 32, 22)) { value = parseConditionalExpression(parser, context, privateScope, value, tokenStart); } } } else if ((parser.getToken() & 2097152) === 2097152) { value = parser.getToken() === 69271571 ? parseArrayExpressionOrPattern(parser, context, scope, privateScope, 0, inGroup, isPattern, kind, origin) : parseObjectLiteralOrPattern(parser, context, scope, privateScope, 0, inGroup, isPattern, kind, origin); destructible = parser.destructible; parser.assignable = destructible & 16 ? 2 : 1; if (parser.getToken() === 18 || parser.getToken() === 1074790415) { if (parser.assignable & 2) destructible |= 16; } else if (parser.destructible & 8) { parser.report(71); } else { value = parseMemberOrUpdateExpression(parser, context, privateScope, value, inGroup, 0, tokenStart); destructible = parser.assignable & 2 ? 16 : 0; if ((parser.getToken() & 4194304) === 4194304) { value = parseAssignmentExpressionOrPattern(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } else { if ((parser.getToken() & 8388608) === 8388608) { value = parseBinaryExpression(parser, context, privateScope, 1, tokenStart, 4, token, value); } if (consumeOpt(parser, context | 32, 22)) { value = parseConditionalExpression(parser, context, privateScope, value, tokenStart); } destructible |= parser.assignable & 2 ? 16 : 32; } } } else { value = parseLeftHandSideExpression(parser, context, privateScope, 1, inGroup, 1); destructible |= parser.assignable & 1 ? 32 : 16; if (parser.getToken() === 18 || parser.getToken() === 1074790415) { if (parser.assignable & 2) destructible |= 16; } else { value = parseMemberOrUpdateExpression(parser, context, privateScope, value, inGroup, 0, tokenStart); destructible = parser.assignable & 2 ? 16 : 0; if (parser.getToken() !== 18 && token !== 1074790415) { if (parser.getToken() !== 1077936155) destructible |= 16; value = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } } } } else if (parser.getToken() === 69271571) { destructible |= 16; if (token === 209005) state |= 16; state |= (token === 209008 ? 256 : token === 209009 ? 512 : 1) | 2; key = parseComputedPropertyName(parser, context, privateScope, inGroup); destructible |= parser.assignable; value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); } else if (parser.getToken() & 143360) { destructible |= 16; if (token === -2147483528) parser.report(95); if (token === 209005) { if (parser.flags & 1) parser.report(132); state |= 16 | 1; } else if (token === 209008) { state |= 256; } else if (token === 209009) { state |= 512; } else { parser.report(0); } key = parseIdentifier(parser, context); value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); } else if (parser.getToken() === 67174411) { destructible |= 16; state |= 1; value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); } else if (parser.getToken() === 8391476) { destructible |= 16; if (token === 209008) { parser.report(42); } else if (token === 209009) { parser.report(43); } else if (token !== 209005) { parser.report(30, KeywordDescTable[8391476 & 255]); } nextToken(parser, context); state |= 8 | 1 | (token === 209005 ? 16 : 0); if (parser.getToken() & 143360) { key = parseIdentifier(parser, context); } else if ((parser.getToken() & 134217728) === 134217728) { key = parseLiteral(parser, context); } else if (parser.getToken() === 69271571) { state |= 2; key = parseComputedPropertyName(parser, context, privateScope, inGroup); destructible |= parser.assignable; } else { parser.report(30, KeywordDescTable[parser.getToken() & 255]); } value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); } else if ((parser.getToken() & 134217728) === 134217728) { if (token === 209005) state |= 16; state |= token === 209008 ? 256 : token === 209009 ? 512 : 1; destructible |= 16; key = parseLiteral(parser, context); value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); } else { parser.report(133); } } else if ((parser.getToken() & 134217728) === 134217728) { key = parseLiteral(parser, context); if (parser.getToken() === 21) { consume(parser, context | 32, 21); const { tokenStart } = parser; if (tokenValue === '__proto__') prototypeCount++; if (parser.getToken() & 143360) { value = parsePrimaryExpression(parser, context, privateScope, kind, 0, 1, inGroup, 1, tokenStart); const { tokenValue: valueAfterColon } = parser; const token = parser.getToken(); value = parseMemberOrUpdateExpression(parser, context, privateScope, value, inGroup, 0, tokenStart); if (parser.getToken() === 18 || parser.getToken() === 1074790415) { if (token === 1077936155 || token === 1074790415 || token === 18) { if (parser.assignable & 2) { destructible |= 16; } else { scope?.addVarOrBlock(context, valueAfterColon, kind, origin); } } else { destructible |= parser.assignable & 1 ? 32 : 16; } } else if (parser.getToken() === 1077936155) { if (parser.assignable & 2) destructible |= 16; value = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } else { destructible |= 16; value = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } } else if ((parser.getToken() & 2097152) === 2097152) { value = parser.getToken() === 69271571 ? parseArrayExpressionOrPattern(parser, context, scope, privateScope, 0, inGroup, isPattern, kind, origin) : parseObjectLiteralOrPattern(parser, context, scope, privateScope, 0, inGroup, isPattern, kind, origin); destructible = parser.destructible; parser.assignable = destructible & 16 ? 2 : 1; if (parser.getToken() === 18 || parser.getToken() === 1074790415) { if (parser.assignable & 2) { destructible |= 16; } } else if ((parser.destructible & 8) !== 8) { value = parseMemberOrUpdateExpression(parser, context, privateScope, value, inGroup, 0, tokenStart); destructible = parser.assignable & 2 ? 16 : 0; if ((parser.getToken() & 4194304) === 4194304) { value = parseAssignmentExpressionOrPattern(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } else { if ((parser.getToken() & 8388608) === 8388608) { value = parseBinaryExpression(parser, context, privateScope, 1, tokenStart, 4, token, value); } if (consumeOpt(parser, context | 32, 22)) { value = parseConditionalExpression(parser, context, privateScope, value, tokenStart); } destructible |= parser.assignable & 2 ? 16 : 32; } } } else { value = parseLeftHandSideExpression(parser, context, privateScope, 1, 0, 1); destructible |= parser.assignable & 1 ? 32 : 16; if (parser.getToken() === 18 || parser.getToken() === 1074790415) { if (parser.assignable & 2) { destructible |= 16; } } else { value = parseMemberOrUpdateExpression(parser, context, privateScope, value, inGroup, 0, tokenStart); destructible = parser.assignable & 1 ? 0 : 16; if (parser.getToken() !== 18 && parser.getToken() !== 1074790415) { if (parser.getToken() !== 1077936155) destructible |= 16; value = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } } } } else if (parser.getToken() === 67174411) { state |= 1; value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); destructible = parser.assignable | 16; } else { parser.report(134); } } else if (parser.getToken() === 69271571) { key = parseComputedPropertyName(parser, context, privateScope, inGroup); destructible |= parser.destructible & 256 ? 256 : 0; state |= 2; if (parser.getToken() === 21) { nextToken(parser, context | 32); const { tokenStart, tokenValue } = parser; const tokenAfterColon = parser.getToken(); if (parser.getToken() & 143360) { value = parsePrimaryExpression(parser, context, privateScope, kind, 0, 1, inGroup, 1, tokenStart); const token = parser.getToken(); value = parseMemberOrUpdateExpression(parser, context, privateScope, value, inGroup, 0, tokenStart); if ((parser.getToken() & 4194304) === 4194304) { destructible |= parser.assignable & 2 ? 16 : token === 1077936155 ? 0 : 32; value = parseAssignmentExpressionOrPattern(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } else if (parser.getToken() === 18 || parser.getToken() === 1074790415) { if (token === 1077936155 || token === 1074790415 || token === 18) { if (parser.assignable & 2) { destructible |= 16; } else if ((tokenAfterColon & 143360) === 143360) { scope?.addVarOrBlock(context, tokenValue, kind, origin); } } else { destructible |= parser.assignable & 1 ? 32 : 16; } } else { destructible |= 16; value = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } } else if ((parser.getToken() & 2097152) === 2097152) { value = parser.getToken() === 69271571 ? parseArrayExpressionOrPattern(parser, context, scope, privateScope, 0, inGroup, isPattern, kind, origin) : parseObjectLiteralOrPattern(parser, context, scope, privateScope, 0, inGroup, isPattern, kind, origin); destructible = parser.destructible; parser.assignable = destructible & 16 ? 2 : 1; if (parser.getToken() === 18 || parser.getToken() === 1074790415) { if (parser.assignable & 2) destructible |= 16; } else if (destructible & 8) { parser.report(62); } else { value = parseMemberOrUpdateExpression(parser, context, privateScope, value, inGroup, 0, tokenStart); destructible = parser.assignable & 2 ? destructible | 16 : 0; if ((parser.getToken() & 4194304) === 4194304) { if (parser.getToken() !== 1077936155) destructible |= 16; value = parseAssignmentExpressionOrPattern(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } else { if ((parser.getToken() & 8388608) === 8388608) { value = parseBinaryExpression(parser, context, privateScope, 1, tokenStart, 4, token, value); } if (consumeOpt(parser, context | 32, 22)) { value = parseConditionalExpression(parser, context, privateScope, value, tokenStart); } destructible |= parser.assignable & 2 ? 16 : 32; } } } else { value = parseLeftHandSideExpression(parser, context, privateScope, 1, 0, 1); destructible |= parser.assignable & 1 ? 32 : 16; if (parser.getToken() === 18 || parser.getToken() === 1074790415) { if (parser.assignable & 2) destructible |= 16; } else { value = parseMemberOrUpdateExpression(parser, context, privateScope, value, inGroup, 0, tokenStart); destructible = parser.assignable & 1 ? 0 : 16; if (parser.getToken() !== 18 && parser.getToken() !== 1074790415) { if (parser.getToken() !== 1077936155) destructible |= 16; value = parseAssignmentExpression(parser, context, privateScope, inGroup, isPattern, tokenStart, value); } } } } else if (parser.getToken() === 67174411) { state |= 1; value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); destructible = 16; } else { parser.report(44); } } else if (token === 8391476) { consume(parser, context | 32, 8391476); state |= 8; if (parser.getToken() & 143360) { const token = parser.getToken(); key = parseIdentifier(parser, context); state |= 1; if (parser.getToken() === 67174411) { destructible |= 16; value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); } else { throw new ParseError(parser.tokenStart, parser.currentLocation, token === 209005 ? 46 : token === 209008 || parser.getToken() === 209009 ? 45 : 47, KeywordDescTable[token & 255]); } } else if ((parser.getToken() & 134217728) === 134217728) { destructible |= 16; key = parseLiteral(parser, context); state |= 1; value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); } else if (parser.getToken() === 69271571) { destructible |= 16; state |= 2 | 1; key = parseComputedPropertyName(parser, context, privateScope, inGroup); value = parseMethodDefinition(parser, context, privateScope, state, inGroup, parser.tokenStart); } else { parser.report(126); } } else { parser.report(30, KeywordDescTable[token & 255]); } destructible |= parser.destructible & 128 ? 128 : 0; parser.destructible = destructible; properties.push(parser.finishNode({ type: 'Property', key: key, value, kind: !(state & 768) ? 'init' : state & 512 ? 'set' : 'get', computed: (state & 2) > 0, method: (state & 1) > 0, shorthand: (state & 4) > 0, }, tokenStart)); } destructible |= parser.destructible; if (parser.getToken() !== 18) break; nextToken(parser, context); } consume(parser, context, 1074790415); if (prototypeCount > 1) destructible |= 64; const node = parser.finishNode({ type: isPattern ? 'ObjectPattern' : 'ObjectExpression', properties, }, start); if (!skipInitializer && parser.getToken() & 4194304) { return parseArrayOrObjectAssignmentPattern(parser, context, privateScope, destructible, inGroup, isPattern, start, node); } parser.destructible = destructible; return node; } function parseMethodFormals(parser, context, scope, privateScope, kind, type, inGroup) { consume(parser, context, 67174411); const params = []; parser.flags = (parser.flags | 128) ^ 128; if (parser.getToken() === 16) { if (kind & 512) { parser.report(37, 'Setter', 'one', ''); } nextToken(parser, context); return params; } if (kind & 256) { parser.report(37, 'Getter', 'no', 's'); } if (kind & 512 && parser.getToken() === 14) { parser.report(38); } context = (context | 131072) ^ 131072; let setterArgs = 0; let isNonSimpleParameterList = 0; while (parser.getToken() !== 18) { let left = null; const { tokenStart } = parser; if (parser.getToken() & 143360) { if ((context & 1) === 0) { if ((parser.getToken() & 36864) === 36864) { parser.flags |= 256; } if ((parser.getToken() & 537079808) === 537079808) { parser.flags |= 512; } } left = parseAndClassifyIdentifier(parser, context, scope, kind | 1, 0); } else { if (parser.getToken() === 2162700) { left = parseObjectLiteralOrPattern(parser, context, scope, privateScope, 1, inGroup, 1, type, 0); } else if (parser.getToken() === 69271571) { left = parseArrayExpressionOrPattern(parser, context, scope, privateScope, 1, inGroup, 1, type, 0); } else if (parser.getToken() === 14) { left = parseSpreadOrRestElement(parser, context, scope, privateScope, 16, type, 0, 0, inGroup, 1); } isNonSimpleParameterList = 1; if (parser.destructible & (32 | 16)) parser.report(50); } if (parser.getToken() === 1077936155) { nextToken(parser, context | 32); isNonSimpleParameterList = 1; const right = parseExpression(parser, context, privateScope, 1, 0, parser.tokenStart); left = parser.finishNode({ type: 'AssignmentPattern', left: left, right, }, tokenStart); } setterArgs++; params.push(left); if (!consumeOpt(parser, context, 18)) break; if (parser.getToken() === 16) { break; } } if (kind & 512 && setterArgs !== 1) { parser.report(37, 'Setter', 'one', ''); } scope?.reportScopeError(); if (isNonSimpleParameterList) parser.flags |= 128; consume(parser, context, 16); return params; } function parseComputedPropertyName(parser, context, privateScope, inGroup) { nextToken(parser, context | 32); const key = parseExpression(parser, (context | 131072) ^ 131072, privateScope, 1, inGroup, parser.tokenStart); consume(parser, context, 20); return key; } function parseParenthesizedExpression(parser, context, privateScope, canAssign, kind, origin, start) { parser.flags = (parser.flags | 128) ^ 128; const parenthesesStart = parser.tokenStart; nextToken(parser, context | 32 | 262144); const scope = parser.createScopeIfLexical()?.createChildScope(512); context = (context | 131072) ^ 131072; if (consumeOpt(parser, context, 16)) { return parseParenthesizedArrow(parser, context, scope, privateScope, [], canAssign, 0, start); } let destructible = 0; parser.destructible &= -385; let expr; let expressions = []; let isSequence = 0; let isNonSimpleParameterList = 0; let hasStrictReserved = 0; const tokenAfterParenthesesStart = parser.tokenStart; parser.assignable = 1; while (parser.getToken() !== 16) { const { tokenStart } = parser; const token = parser.getToken(); if (token & 143360) { scope?.addBlockName(context, parser.tokenValue, 1, 0); if ((token & 537079808) === 537079808) { isNonSimpleParameterList = 1; } else if ((token & 36864) === 36864) { hasStrictReserved = 1; } expr = parsePrimaryExpression(parser, context, privateScope, kind, 0, 1, 1, 1, tokenStart); if (parser.getToken() === 16 || parser.getToken() === 18) { if (parser.assignable & 2) { destructible |= 16; isNonSimpleParameterList = 1; } } else { if (parser.getToken() === 1077936155) { isNonSimpleParameterList = 1; } else { destructible |= 16; } expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, 1, 0, tokenStart); if (parser.getToken() !== 16 && parser.getToken() !== 18) { expr = parseAssignmentExpression(parser, context, privateScope, 1, 0, tokenStart, expr); } } } else if ((token & 2097152) === 2097152) { expr = token === 2162700 ? parseObjectLiteralOrPattern(parser, context | 262144, scope, privateScope, 0, 1, 0, kind, origin) : parseArrayExpressionOrPattern(parser, context | 262144, scope, privateScope, 0, 1, 0, kind, origin); destructible |= parser.destructible; isNonSimpleParameterList = 1; parser.assignable = 2; if (parser.getToken() !== 16 && parser.getToken() !== 18) { if (destructible & 8) parser.report(122); expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, 0, 0, tokenStart); destructible |= 16; if (parser.getToken() !== 16 && parser.getToken() !== 18) { expr = parseAssignmentExpression(parser, context, privateScope, 0, 0, tokenStart, expr); } } } else if (token === 14) { expr = parseSpreadOrRestElement(parser, context, scope, privateScope, 16, kind, origin, 0, 1, 0); if (parser.destructible & 16) parser.report(74); isNonSimpleParameterList = 1; if (isSequence && (parser.getToken() === 16 || parser.getToken() === 18)) { expressions.push(expr); } destructible |= 8; break; } else { destructible |= 16; expr = parseExpression(parser, context, privateScope, 1, 1, tokenStart); if (isSequence && (parser.getToken() === 16 || parser.getToken() === 18)) { expressions.push(expr); } if (parser.getToken() === 18) { if (!isSequence) { isSequence = 1; expressions = [expr]; } } if (isSequence) { while (consumeOpt(parser, context | 32, 18)) { expressions.push(parseExpression(parser, context, privateScope, 1, 1, parser.tokenStart)); } parser.assignable = 2; expr = parser.finishNode({ type: 'SequenceExpression', expressions, }, tokenAfterParenthesesStart); } consume(parser, context, 16); parser.destructible = destructible; return parser.options.preserveParens ? parser.finishNode({ type: 'ParenthesizedExpression', expression: expr, }, parenthesesStart) : expr; } if (isSequence && (parser.getToken() === 16 || parser.getToken() === 18)) { expressions.push(expr); } if (!consumeOpt(parser, context | 32, 18)) break; if (!isSequence) { isSequence = 1; expressions = [expr]; } if (parser.getToken() === 16) { destructible |= 8; break; } } if (isSequence) { parser.assignable = 2; expr = parser.finishNode({ type: 'SequenceExpression', expressions, }, tokenAfterParenthesesStart); } consume(parser, context, 16); if (destructible & 16 && destructible & 8) parser.report(151); destructible |= parser.destructible & 256 ? 256 : 0 | (parser.destructible & 128) ? 128 : 0; if (parser.getToken() === 10) { if (destructible & (32 | 16)) parser.report(49); if (context & (2048 | 2) && destructible & 128) parser.report(31); if (context & (1 | 1024) && destructible & 256) { parser.report(32); } if (isNonSimpleParameterList) parser.flags |= 128; if (hasStrictReserved) parser.flags |= 256; return parseParenthesizedArrow(parser, context, scope, privateScope, isSequence ? expressions : [expr], canAssign, 0, start); } if (destructible & 64) { parser.report(63); } if (destructible & 8) { parser.report(144); } parser.destructible = ((parser.destructible | 256) ^ 256) | destructible; return parser.options.preserveParens ? parser.finishNode({ type: 'ParenthesizedExpression', expression: expr, }, parenthesesStart) : expr; } function parseIdentifierOrArrow(parser, context, privateScope) { const { tokenStart: start } = parser; const { tokenValue } = parser; let isNonSimpleParameterList = 0; let hasStrictReserved = 0; if ((parser.getToken() & 537079808) === 537079808) { isNonSimpleParameterList = 1; } else if ((parser.getToken() & 36864) === 36864) { hasStrictReserved = 1; } const expr = parseIdentifier(parser, context); parser.assignable = 1; if (parser.getToken() === 10) { const scope = parser.options.lexical ? createArrowHeadParsingScope(parser, context, tokenValue) : undefined; if (isNonSimpleParameterList) parser.flags |= 128; if (hasStrictReserved) parser.flags |= 256; return parseArrowFunctionExpression(parser, context, scope, privateScope, [expr], 0, start); } return expr; } function parseArrowFromIdentifier(parser, context, privateScope, value, expr, inNew, canAssign, isAsync, start) { if (!canAssign) parser.report(57); if (inNew) parser.report(51); parser.flags &= -129; const scope = parser.options.lexical ? createArrowHeadParsingScope(parser, context, value) : void 0; return parseArrowFunctionExpression(parser, context, scope, privateScope, [expr], isAsync, start); } function parseParenthesizedArrow(parser, context, scope, privateScope, params, canAssign, isAsync, start) { if (!canAssign) parser.report(57); for (let i = 0; i < params.length; ++i) reinterpretToPattern(parser, params[i]); return parseArrowFunctionExpression(parser, context, scope, privateScope, params, isAsync, start); } function parseArrowFunctionExpression(parser, context, scope, privateScope, params, isAsync, start) { if (parser.flags & 1) parser.report(48); consume(parser, context | 32, 10); const modifierFlags = 1024 | 2048 | 8192 | 524288; context = ((context | modifierFlags) ^ modifierFlags) | (isAsync ? 2048 : 0); const expression = parser.getToken() !== 2162700; let body; scope?.reportScopeError(); if (expression) { parser.flags = (parser.flags | 512 | 256 | 64 | 4096) ^ (512 | 256 | 64 | 4096); body = parseExpression(parser, context, privateScope, 1, 0, parser.tokenStart); } else { scope = scope?.createChildScope(64); const modifierFlags = 4 | 131072 | 8; body = parseFunctionBody(parser, ((context | modifierFlags) ^ modifierFlags) | 4096, scope, privateScope, 16, void 0, void 0); switch (parser.getToken()) { case 69271571: if ((parser.flags & 1) === 0) { parser.report(116); } break; case 67108877: case 67174409: case 22: parser.report(117); case 67174411: if ((parser.flags & 1) === 0) { parser.report(116); } parser.flags |= 1024; break; } if ((parser.getToken() & 8388608) === 8388608 && (parser.flags & 1) === 0) parser.report(30, KeywordDescTable[parser.getToken() & 255]); if ((parser.getToken() & 33619968) === 33619968) parser.report(125); } parser.assignable = 2; return parser.finishNode({ type: 'ArrowFunctionExpression', params, body, async: isAsync === 1, expression, generator: false, }, start); } function parseFormalParametersOrFormalList(parser, context, scope, privateScope, inGroup, kind) { consume(parser, context, 67174411); parser.flags = (parser.flags | 128) ^ 128; const params = []; if (consumeOpt(parser, context, 16)) return params; context = (context | 131072) ^ 131072; let isNonSimpleParameterList = 0; while (parser.getToken() !== 18) { let left; const { tokenStart } = parser; const token = parser.getToken(); if (token & 143360) { if ((context & 1) === 0) { if ((token & 36864) === 36864) { parser.flags |= 256; } if ((token & 537079808) === 537079808) { parser.flags |= 512; } } left = parseAndClassifyIdentifier(parser, context, scope, kind | 1, 0); } else { if (token === 2162700) { left = parseObjectLiteralOrPattern(parser, context, scope, privateScope, 1, inGroup, 1, kind, 0); } else if (token === 69271571) { left = parseArrayExpressionOrPattern(parser, context, scope, privateScope, 1, inGroup, 1, kind, 0); } else if (token === 14) { left = parseSpreadOrRestElement(parser, context, scope, privateScope, 16, kind, 0, 0, inGroup, 1); } else { parser.report(30, KeywordDescTable[token & 255]); } isNonSimpleParameterList = 1; if (parser.destructible & (32 | 16)) { parser.report(50); } } if (parser.getToken() === 1077936155) { nextToken(parser, context | 32); isNonSimpleParameterList = 1; const right = parseExpression(parser, context, privateScope, 1, inGroup, parser.tokenStart); left = parser.finishNode({ type: 'AssignmentPattern', left, right, }, tokenStart); } params.push(left); if (!consumeOpt(parser, context, 18)) break; if (parser.getToken() === 16) { break; } } if (isNonSimpleParameterList) parser.flags |= 128; if (isNonSimpleParameterList || context & 1) { scope?.reportScopeError(); } consume(parser, context, 16); return params; } function parseMemberExpressionNoCall(parser, context, privateScope, expr, inGroup, start) { const token = parser.getToken(); if (token & 67108864) { if (token === 67108877) { nextToken(parser, context | 262144); parser.assignable = 1; const property = parsePropertyOrPrivatePropertyName(parser, context, privateScope); return parseMemberExpressionNoCall(parser, context, privateScope, parser.finishNode({ type: 'MemberExpression', object: expr, computed: false, property, optional: false, }, start), 0, start); } else if (token === 69271571) { nextToken(parser, context | 32); const { tokenStart } = parser; const property = parseExpressions(parser, context, privateScope, inGroup, 1, tokenStart); consume(parser, context, 20); parser.assignable = 1; return parseMemberExpressionNoCall(parser, context, privateScope, parser.finishNode({ type: 'MemberExpression', object: expr, computed: true, property, optional: false, }, start), 0, start); } else if (token === 67174408 || token === 67174409) { parser.assignable = 2; return parseMemberExpressionNoCall(parser, context, privateScope, parser.finishNode({ type: 'TaggedTemplateExpression', tag: expr, quasi: parser.getToken() === 67174408 ? parseTemplate(parser, context | 64, privateScope) : parseTemplateLiteral(parser, context | 64), }, start), 0, start); } } return expr; } function parseNewExpression(parser, context, privateScope, inGroup) { const { tokenStart: start } = parser; const id = parseIdentifier(parser, context | 32); const { tokenStart } = parser; if (consumeOpt(parser, context, 67108877)) { if (context & 65536 && parser.getToken() === 209029) { parser.assignable = 2; return parseMetaProperty(parser, context, id, start); } parser.report(94); } parser.assignable = 2; if ((parser.getToken() & 16842752) === 16842752) { parser.report(65, KeywordDescTable[parser.getToken() & 255]); } const expr = parsePrimaryExpression(parser, context, privateScope, 2, 1, 0, inGroup, 1, tokenStart); context = (context | 131072) ^ 131072; if (parser.getToken() === 67108990) parser.report(168); const callee = parseMemberExpressionNoCall(parser, context, privateScope, expr, inGroup, tokenStart); parser.assignable = 2; return parser.finishNode({ type: 'NewExpression', callee, arguments: parser.getToken() === 67174411 ? parseArguments(parser, context, privateScope, inGroup) : [], }, start); } function parseMetaProperty(parser, context, meta, start) { const property = parseIdentifier(parser, context); return parser.finishNode({ type: 'MetaProperty', meta, property, }, start); } function parseAsyncArrowAfterIdent(parser, context, privateScope, canAssign, start) { if (parser.getToken() === 209006) parser.report(31); if (context & (1 | 1024) && parser.getToken() === 241771) { parser.report(32); } classifyIdentifier(parser, context, parser.getToken()); if ((parser.getToken() & 36864) === 36864) { parser.flags |= 256; } return parseArrowFromIdentifier(parser, (context & -524289) | 2048, privateScope, parser.tokenValue, parseIdentifier(parser, context), 0, canAssign, 1, start); } function parseAsyncArrowOrCallExpression(parser, context, privateScope, callee, canAssign, kind, origin, flags, start) { nextToken(parser, context | 32); const scope = parser.createScopeIfLexical()?.createChildScope(512); context = (context | 131072) ^ 131072; if (consumeOpt(parser, context, 16)) { if (parser.getToken() === 10) { if (flags & 1) parser.report(48); return parseParenthesizedArrow(parser, context, scope, privateScope, [], canAssign, 1, start); } return parser.finishNode({ type: 'CallExpression', callee, arguments: [], optional: false, }, start); } let destructible = 0; let expr = null; let isNonSimpleParameterList = 0; parser.destructible = (parser.destructible | 256 | 128) ^ (256 | 128); const params = []; while (parser.getToken() !== 16) { const { tokenStart } = parser; const token = parser.getToken(); if (token & 143360) { scope?.addBlockName(context, parser.tokenValue, kind, 0); if ((token & 537079808) === 537079808) { parser.flags |= 512; } else if ((token & 36864) === 36864) { parser.flags |= 256; } expr = parsePrimaryExpression(parser, context, privateScope, kind, 0, 1, 1, 1, tokenStart); if (parser.getToken() === 16 || parser.getToken() === 18) { if (parser.assignable & 2) { destructible |= 16; isNonSimpleParameterList = 1; } } else { if (parser.getToken() === 1077936155) { isNonSimpleParameterList = 1; } else { destructible |= 16; } expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, 1, 0, tokenStart); if (parser.getToken() !== 16 && parser.getToken() !== 18) { expr = parseAssignmentExpression(parser, context, privateScope, 1, 0, tokenStart, expr); } } } else if (token & 2097152) { expr = token === 2162700 ? parseObjectLiteralOrPattern(parser, context, scope, privateScope, 0, 1, 0, kind, origin) : parseArrayExpressionOrPattern(parser, context, scope, privateScope, 0, 1, 0, kind, origin); destructible |= parser.destructible; isNonSimpleParameterList = 1; if (parser.getToken() !== 16 && parser.getToken() !== 18) { if (destructible & 8) parser.report(122); expr = parseMemberOrUpdateExpression(parser, context, privateScope, expr, 0, 0, tokenStart); destructible |= 16; if ((parser.getToken() & 8388608) === 8388608) { expr = parseBinaryExpression(parser, context, privateScope, 1, start, 4, token, expr); } if (consumeOpt(parser, context | 32, 22)) { expr = parseConditionalExpression(parser, context, privateScope, expr, start); } } } else if (token === 14) { expr = parseSpreadOrRestElement(parser, context, scope, privateScope, 16, kind, origin, 1, 1, 0); destructible |= (parser.getToken() === 16 ? 0 : 16) | parser.destructible; isNonSimpleParameterList = 1; } else { expr = parseExpression(parser, context, privateScope, 1, 0, tokenStart); destructible = parser.assignable; params.push(expr); while (consumeOpt(parser, context | 32, 18)) { params.push(parseExpression(parser, context, privateScope, 1, 0, tokenStart)); } destructible |= parser.assignable; consume(parser, context, 16); parser.destructible = destructible | 16; parser.assignable = 2; return parser.finishNode({ type: 'CallExpression', callee, arguments: params, optional: false, }, start); } params.push(expr); if (!consumeOpt(parser, context | 32, 18)) break; } consume(parser, context, 16); destructible |= parser.destructible & 256 ? 256 : 0 | (parser.destructible & 128) ? 128 : 0; if (parser.getToken() === 10) { if (destructible & (32 | 16)) parser.report(27); if (parser.flags & 1 || flags & 1) parser.report(48); if (destructible & 128) parser.report(31); if (context & (1 | 1024) && destructible & 256) parser.report(32); if (isNonSimpleParameterList) parser.flags |= 128; return parseParenthesizedArrow(parser, context | 2048, scope, privateScope, params, canAssign, 1, start); } if (destructible & 64) { parser.report(63); } if (destructible & 8) { parser.report(62); } parser.assignable = 2; return parser.finishNode({ type: 'CallExpression', callee, arguments: params, optional: false, }, start); } function parseRegExpLiteral(parser, context) { const { tokenRaw, tokenRegExp, tokenValue, tokenStart } = parser; nextToken(parser, context); parser.assignable = 2; const node = { type: 'Literal', value: tokenValue, regex: tokenRegExp, }; if (parser.options.raw) { node.raw = tokenRaw; } return parser.finishNode(node, tokenStart); } function parseClassDeclaration(parser, context, scope, privateScope, flags) { let start; let decorators; if (parser.leadingDecorators.decorators.length) { if (parser.getToken() === 132) { parser.report(30, '@'); } start = parser.leadingDecorators.start; decorators = [...parser.leadingDecorators.decorators]; parser.leadingDecorators.decorators.length = 0; } else { start = parser.tokenStart; decorators = parseDecorators(parser, context, privateScope); } context = (context | 16384 | 1) ^ 16384; nextToken(parser, context); let id = null; let superClass = null; const { tokenValue } = parser; if (parser.getToken() & 4096 && parser.getToken() !== 20565) { if (isStrictReservedWord(parser, context, parser.getToken())) { parser.report(118); } if ((parser.getToken() & 537079808) === 537079808) { parser.report(119); } if (scope) { scope.addBlockName(context, tokenValue, 32, 0); if (flags) { if (flags & 2) { parser.declareUnboundVariable(tokenValue); } } } id = parseIdentifier(parser, context); } else { if ((flags & 1) === 0) parser.report(39, 'Class'); } let inheritedContext = context; if (consumeOpt(parser, context | 32, 20565)) { superClass = parseLeftHandSideExpression(parser, context, privateScope, 0, 0, 0); inheritedContext |= 512; } else { inheritedContext = (inheritedContext | 512) ^ 512; } const body = parseClassBody(parser, inheritedContext, context, scope, privateScope, 2, 8, 0); return parser.finishNode({ type: 'ClassDeclaration', id, superClass, body, ...(parser.options.next ? { decorators } : null), }, start); } function parseClassExpression(parser, context, privateScope, inGroup, start) { let id = null; let superClass = null; const decorators = parseDecorators(parser, context, privateScope); context = (context | 1 | 16384) ^ 16384; nextToken(parser, context); if (parser.getToken() & 4096 && parser.getToken() !== 20565) { if (isStrictReservedWord(parser, context, parser.getToken())) parser.report(118); if ((parser.getToken() & 537079808) === 537079808) { parser.report(119); } id = parseIdentifier(parser, context); } let inheritedContext = context; if (consumeOpt(parser, context | 32, 20565)) { superClass = parseLeftHandSideExpression(parser, context, privateScope, 0, inGroup, 0); inheritedContext |= 512; } else { inheritedContext = (inheritedContext | 512) ^ 512; } const body = parseClassBody(parser, inheritedContext, context, void 0, privateScope, 2, 0, inGroup); parser.assignable = 2; return parser.finishNode({ type: 'ClassExpression', id, superClass, body, ...(parser.options.next ? { decorators } : null), }, start); } function parseDecorators(parser, context, privateScope) { const list = []; if (parser.options.next) { while (parser.getToken() === 132) { list.push(parseDecoratorList(parser, context, privateScope)); } } return list; } function parseDecoratorList(parser, context, privateScope) { const start = parser.tokenStart; nextToken(parser, context | 32); let expression = parsePrimaryExpression(parser, context, privateScope, 2, 0, 1, 0, 1, start); expression = parseMemberOrUpdateExpression(parser, context, privateScope, expression, 0, 0, parser.tokenStart); return parser.finishNode({ type: 'Decorator', expression, }, start); } function parseClassBody(parser, context, inheritedContext, scope, parentScope, kind, origin, inGroup) { const { tokenStart } = parser; const privateScope = parser.createPrivateScopeIfLexical(parentScope); consume(parser, context | 32, 2162700); const modifierFlags = 131072 | 524288; context = (context | modifierFlags) ^ modifierFlags; const hasConstr = parser.flags & 32; parser.flags = (parser.flags | 32) ^ 32; const body = []; while (parser.getToken() !== 1074790415) { const decoratorStart = parser.tokenStart; const decorators = parseDecorators(parser, context, privateScope); if (decorators.length > 0 && parser.tokenValue === 'constructor') { parser.report(109); } if (parser.getToken() === 1074790415) parser.report(108); if (consumeOpt(parser, context, 1074790417)) { if (decorators.length > 0) parser.report(120); continue; } body.push(parseClassElementList(parser, context, scope, privateScope, inheritedContext, kind, decorators, 0, inGroup, decorators.length > 0 ? decoratorStart : parser.tokenStart)); } consume(parser, origin & 8 ? context | 32 : context, 1074790415); privateScope?.validatePrivateIdentifierRefs(); parser.flags = (parser.flags & -33) | hasConstr; return parser.finishNode({ type: 'ClassBody', body, }, tokenStart); } function parseClassElementList(parser, context, scope, privateScope, inheritedContext, type, decorators, isStatic, inGroup, start) { let kind = isStatic ? 32 : 0; let key = null; const token = parser.getToken(); if (token & (143360 | 36864) || token === -2147483528) { key = parseIdentifier(parser, context); switch (token) { case 36970: if (!isStatic && parser.getToken() !== 67174411 && (parser.getToken() & 1048576) !== 1048576 && parser.getToken() !== 1077936155) { return parseClassElementList(parser, context, scope, privateScope, inheritedContext, type, decorators, 1, inGroup, start); } break; case 209005: if (parser.getToken() !== 67174411 && (parser.flags & 1) === 0) { if ((parser.getToken() & 1073741824) === 1073741824) { return parsePropertyDefinition(parser, context, privateScope, key, kind, decorators, start); } kind |= 16 | (optionalBit(parser, context, 8391476) ? 8 : 0); } break; case 209008: if (parser.getToken() !== 67174411) { if ((parser.getToken() & 1073741824) === 1073741824) { return parsePropertyDefinition(parser, context, privateScope, key, kind, decorators, start); } kind |= 256; } break; case 209009: if (parser.getToken() !== 67174411) { if ((parser.getToken() & 1073741824) === 1073741824) { return parsePropertyDefinition(parser, context, privateScope, key, kind, decorators, start); } kind |= 512; } break; case 12402: if (parser.getToken() !== 67174411 && (parser.flags & 1) === 0) { if ((parser.getToken() & 1073741824) === 1073741824) { return parsePropertyDefinition(parser, context, privateScope, key, kind, decorators, start); } if (parser.options.next) kind |= 1024; } break; } } else if (token === 69271571) { kind |= 2; key = parseComputedPropertyName(parser, inheritedContext, privateScope, inGroup); } else if ((token & 134217728) === 134217728) { key = parseLiteral(parser, context); } else if (token === 8391476) { kind |= 8; nextToken(parser, context); } else if (parser.getToken() === 130) { kind |= 8192; key = parsePrivateIdentifier(parser, context | 16, privateScope, 768); } else if ((parser.getToken() & 1073741824) === 1073741824) { kind |= 128; } else if (isStatic && token === 2162700) { return parseStaticBlock(parser, context | 16, scope, privateScope, start); } else if (token === -2147483527) { key = parseIdentifier(parser, context); if (parser.getToken() !== 67174411) parser.report(30, KeywordDescTable[parser.getToken() & 255]); } else { parser.report(30, KeywordDescTable[parser.getToken() & 255]); } if (kind & (8 | 16 | 768 | 1024)) { if (parser.getToken() & 143360 || parser.getToken() === -2147483528 || parser.getToken() === -2147483527) { key = parseIdentifier(parser, context); } else if ((parser.getToken() & 134217728) === 134217728) { key = parseLiteral(parser, context); } else if (parser.getToken() === 69271571) { kind |= 2; key = parseComputedPropertyName(parser, context, privateScope, 0); } else if (parser.getToken() === 130) { kind |= 8192; key = parsePrivateIdentifier(parser, context, privateScope, kind); } else parser.report(135); } if ((kind & 2) === 0) { if (parser.tokenValue === 'constructor') { if ((parser.getToken() & 1073741824) === 1073741824) { parser.report(129); } else if ((kind & 32) === 0 && parser.getToken() === 67174411) { if (kind & (768 | 16 | 128 | 8)) { parser.report(53, 'accessor'); } else if ((context & 512) === 0) { if (parser.flags & 32) parser.report(54); else parser.flags |= 32; } } kind |= 64; } else if ((kind & 8192) === 0 && kind & 32 && parser.tokenValue === 'prototype') { parser.report(52); } } if (kind & 1024 || (parser.getToken() !== 67174411 && (kind & 768) === 0)) { return parsePropertyDefinition(parser, context, privateScope, key, kind, decorators, start); } const value = parseMethodDefinition(parser, context | 16, privateScope, kind, inGroup, parser.tokenStart); return parser.finishNode({ type: 'MethodDefinition', kind: (kind & 32) === 0 && kind & 64 ? 'constructor' : kind & 256 ? 'get' : kind & 512 ? 'set' : 'method', static: (kind & 32) > 0, computed: (kind & 2) > 0, key, value, ...(parser.options.next ? { decorators } : null), }, start); } function parsePrivateIdentifier(parser, context, privateScope, kind) { const { tokenStart } = parser; nextToken(parser, context); const { tokenValue } = parser; if (tokenValue === 'constructor') parser.report(128); if (parser.options.lexical) { if (!privateScope) parser.report(4, tokenValue); if (kind) { privateScope.addPrivateIdentifier(tokenValue, kind); } else { privateScope.addPrivateIdentifierRef(tokenValue); } } nextToken(parser, context); return parser.finishNode({ type: 'PrivateIdentifier', name: tokenValue, }, tokenStart); } function parsePropertyDefinition(parser, context, privateScope, key, state, decorators, start) { let value = null; if (state & 8) parser.report(0); if (parser.getToken() === 1077936155) { nextToken(parser, context | 32); const { tokenStart } = parser; if (parser.getToken() === 537079927) parser.report(119); const modifierFlags = 1024 | 2048 | 8192 | ((state & 64) === 0 ? 512 | 16384 : 0); context = ((context | modifierFlags) ^ modifierFlags) | (state & 8 ? 1024 : 0) | (state & 16 ? 2048 : 0) | (state & 64 ? 16384 : 0) | 256 | 65536; value = parsePrimaryExpression(parser, context | 16, privateScope, 2, 0, 1, 0, 1, tokenStart); if ((parser.getToken() & 1073741824) !== 1073741824 || (parser.getToken() & 4194304) === 4194304) { value = parseMemberOrUpdateExpression(parser, context | 16, privateScope, value, 0, 0, tokenStart); value = parseAssignmentExpression(parser, context | 16, privateScope, 0, 0, tokenStart, value); } } matchOrInsertSemicolon(parser, context); return parser.finishNode({ type: state & 1024 ? 'AccessorProperty' : 'PropertyDefinition', key, value, static: (state & 32) > 0, computed: (state & 2) > 0, ...(parser.options.next ? { decorators } : null), }, start); } function parseBindingPattern(parser, context, scope, privateScope, type, origin) { if (parser.getToken() & 143360 || ((context & 1) === 0 && parser.getToken() === -2147483527)) return parseAndClassifyIdentifier(parser, context, scope, type, origin); if ((parser.getToken() & 2097152) !== 2097152) parser.report(30, KeywordDescTable[parser.getToken() & 255]); const left = parser.getToken() === 69271571 ? parseArrayExpressionOrPattern(parser, context, scope, privateScope, 1, 0, 1, type, origin) : parseObjectLiteralOrPattern(parser, context, scope, privateScope, 1, 0, 1, type, origin); if (parser.destructible & 16) parser.report(50); if (parser.destructible & 32) parser.report(50); return left; } function parseAndClassifyIdentifier(parser, context, scope, kind, origin) { const token = parser.getToken(); if (context & 1) { if ((token & 537079808) === 537079808) { parser.report(119); } else if ((token & 36864) === 36864 || token === -2147483527) { parser.report(118); } } if ((token & 20480) === 20480) { parser.report(102); } if (token === 241771) { if (context & 1024) parser.report(32); if (context & 2) parser.report(111); } if ((token & 255) === (241737 & 255)) { if (kind & (8 | 16)) parser.report(100); } if (token === 209006) { if (context & 2048) parser.report(176); if (context & 2) parser.report(110); } const { tokenValue, tokenStart: start } = parser; nextToken(parser, context); scope?.addVarOrBlock(context, tokenValue, kind, origin); return parser.finishNode({ type: 'Identifier', name: tokenValue, }, start); } function parseJSXRootElementOrFragment(parser, context, privateScope, inJSXChild, start) { if (!inJSXChild) consume(parser, context, 8456256); if (parser.getToken() === 8390721) { const openingFragment = parseJSXOpeningFragment(parser, start); const [children, closingFragment] = parseJSXChildrenAndClosingFragment(parser, context, privateScope, inJSXChild); return parser.finishNode({ type: 'JSXFragment', openingFragment, children, closingFragment, }, start); } if (parser.getToken() === 8457014) parser.report(30, KeywordDescTable[parser.getToken() & 255]); let closingElement = null; let children = []; const openingElement = parseJSXOpeningElementOrSelfCloseElement(parser, context, privateScope, inJSXChild, start); if (!openingElement.selfClosing) { [children, closingElement] = parseJSXChildrenAndClosingElement(parser, context, privateScope, inJSXChild); const close = isEqualTagName(closingElement.name); if (isEqualTagName(openingElement.name) !== close) parser.report(155, close); } return parser.finishNode({ type: 'JSXElement', children, openingElement, closingElement, }, start); } function parseJSXOpeningFragment(parser, start) { nextJSXToken(parser); return parser.finishNode({ type: 'JSXOpeningFragment', }, start); } function parseJSXClosingElement(parser, context, inJSXChild, start) { consume(parser, context, 8457014); const name = parseJSXElementName(parser, context); if (parser.getToken() !== 8390721) { parser.report(25, KeywordDescTable[8390721 & 255]); } if (inJSXChild) { nextJSXToken(parser); } else { nextToken(parser, context); } return parser.finishNode({ type: 'JSXClosingElement', name, }, start); } function parseJSXClosingFragment(parser, context, inJSXChild, start) { consume(parser, context, 8457014); if (parser.getToken() !== 8390721) { parser.report(25, KeywordDescTable[8390721 & 255]); } if (inJSXChild) { nextJSXToken(parser); } else { nextToken(parser, context); } return parser.finishNode({ type: 'JSXClosingFragment', }, start); } function parseJSXChildrenAndClosingElement(parser, context, privateScope, inJSXChild) { const children = []; while (true) { const child = parseJSXChildOrClosingElement(parser, context, privateScope, inJSXChild); if (child.type === 'JSXClosingElement') { return [children, child]; } children.push(child); } } function parseJSXChildrenAndClosingFragment(parser, context, privateScope, inJSXChild) { const children = []; while (true) { const child = parseJSXChildOrClosingFragment(parser, context, privateScope, inJSXChild); if (child.type === 'JSXClosingFragment') { return [children, child]; } children.push(child); } } function parseJSXChildOrClosingElement(parser, context, privateScope, inJSXChild) { if (parser.getToken() === 137) return parseJSXText(parser, context); if (parser.getToken() === 2162700) return parseJSXExpressionContainer(parser, context, privateScope, 1, 0); if (parser.getToken() === 8456256) { const { tokenStart } = parser; nextToken(parser, context); if (parser.getToken() === 8457014) return parseJSXClosingElement(parser, context, inJSXChild, tokenStart); return parseJSXRootElementOrFragment(parser, context, privateScope, 1, tokenStart); } parser.report(0); } function parseJSXChildOrClosingFragment(parser, context, privateScope, inJSXChild) { if (parser.getToken() === 137) return parseJSXText(parser, context); if (parser.getToken() === 2162700) return parseJSXExpressionContainer(parser, context, privateScope, 1, 0); if (parser.getToken() === 8456256) { const { tokenStart } = parser; nextToken(parser, context); if (parser.getToken() === 8457014) return parseJSXClosingFragment(parser, context, inJSXChild, tokenStart); return parseJSXRootElementOrFragment(parser, context, privateScope, 1, tokenStart); } parser.report(0); } function parseJSXText(parser, context) { const start = parser.tokenStart; nextToken(parser, context); const node = { type: 'JSXText', value: parser.tokenValue, }; if (parser.options.raw) { node.raw = parser.tokenRaw; } return parser.finishNode(node, start); } function parseJSXOpeningElementOrSelfCloseElement(parser, context, privateScope, inJSXChild, start) { if ((parser.getToken() & 143360) !== 143360 && (parser.getToken() & 4096) !== 4096) parser.report(0); const tagName = parseJSXElementName(parser, context); const attributes = parseJSXAttributes(parser, context, privateScope); const selfClosing = parser.getToken() === 8457014; if (selfClosing) consume(parser, context, 8457014); if (parser.getToken() !== 8390721) { parser.report(25, KeywordDescTable[8390721 & 255]); } if (inJSXChild || !selfClosing) { nextJSXToken(parser); } else { nextToken(parser, context); } return parser.finishNode({ type: 'JSXOpeningElement', name: tagName, attributes, selfClosing, }, start); } function parseJSXElementName(parser, context) { const { tokenStart } = parser; rescanJSXIdentifier(parser); let key = parseJSXIdentifier(parser, context); if (parser.getToken() === 21) return parseJSXNamespacedName(parser, context, key, tokenStart); while (consumeOpt(parser, context, 67108877)) { rescanJSXIdentifier(parser); key = parseJSXMemberExpression(parser, context, key, tokenStart); } return key; } function parseJSXMemberExpression(parser, context, object, start) { const property = parseJSXIdentifier(parser, context); return parser.finishNode({ type: 'JSXMemberExpression', object, property, }, start); } function parseJSXAttributes(parser, context, privateScope) { const attributes = []; while (parser.getToken() !== 8457014 && parser.getToken() !== 8390721 && parser.getToken() !== 1048576) { attributes.push(parseJsxAttribute(parser, context, privateScope)); } return attributes; } function parseJSXSpreadAttribute(parser, context, privateScope) { const start = parser.tokenStart; nextToken(parser, context); consume(parser, context, 14); const expression = parseExpression(parser, context, privateScope, 1, 0, parser.tokenStart); consume(parser, context, 1074790415); return parser.finishNode({ type: 'JSXSpreadAttribute', argument: expression, }, start); } function parseJsxAttribute(parser, context, privateScope) { const { tokenStart } = parser; if (parser.getToken() === 2162700) return parseJSXSpreadAttribute(parser, context, privateScope); rescanJSXIdentifier(parser); let value = null; let name = parseJSXIdentifier(parser, context); if (parser.getToken() === 21) { name = parseJSXNamespacedName(parser, context, name, tokenStart); } if (parser.getToken() === 1077936155) { const token = scanJSXAttributeValue(parser, context); switch (token) { case 134283267: value = parseLiteral(parser, context); break; case 8456256: value = parseJSXRootElementOrFragment(parser, context, privateScope, 0, parser.tokenStart); break; case 2162700: value = parseJSXExpressionContainer(parser, context, privateScope, 0, 1); break; default: parser.report(154); } } return parser.finishNode({ type: 'JSXAttribute', value, name, }, tokenStart); } function parseJSXNamespacedName(parser, context, namespace, start) { consume(parser, context, 21); const name = parseJSXIdentifier(parser, context); return parser.finishNode({ type: 'JSXNamespacedName', namespace, name, }, start); } function parseJSXExpressionContainer(parser, context, privateScope, inJSXChild, isAttr) { const { tokenStart: start } = parser; nextToken(parser, context | 32); const { tokenStart } = parser; if (parser.getToken() === 14) return parseJSXSpreadChild(parser, context, privateScope, start); let expression = null; if (parser.getToken() === 1074790415) { if (isAttr) parser.report(157); expression = parseJSXEmptyExpression(parser, { index: parser.startIndex, line: parser.startLine, column: parser.startColumn, }); } else { expression = parseExpression(parser, context, privateScope, 1, 0, tokenStart); } if (parser.getToken() !== 1074790415) { parser.report(25, KeywordDescTable[1074790415 & 255]); } if (inJSXChild) { nextJSXToken(parser); } else { nextToken(parser, context); } return parser.finishNode({ type: 'JSXExpressionContainer', expression, }, start); } function parseJSXSpreadChild(parser, context, privateScope, start) { consume(parser, context, 14); const expression = parseExpression(parser, context, privateScope, 1, 0, parser.tokenStart); consume(parser, context, 1074790415); return parser.finishNode({ type: 'JSXSpreadChild', expression, }, start); } function parseJSXEmptyExpression(parser, start) { return parser.finishNode({ type: 'JSXEmptyExpression', }, start, parser.tokenStart); } function parseJSXIdentifier(parser, context) { const start = parser.tokenStart; if (!(parser.getToken() & 143360)) { parser.report(30, KeywordDescTable[parser.getToken() & 255]); } const { tokenValue } = parser; nextToken(parser, context); return parser.finishNode({ type: 'JSXIdentifier', name: tokenValue, }, start); } var version$1 = "6.1.4"; const version = version$1; function parseScript(source, options) { return parseSource(source, options); } function parseModule(source, options) { return parseSource(source, options, 1 | 2); } function parse(source, options) { return parseSource(source, options); } exports.parse = parse; exports.parseModule = parseModule; exports.parseScript = parseScript; exports.version = version; })); ================================================ FILE: app/src/main/assets/solver/yt.solver.core.js ================================================ /*! * SPDX-License-Identifier: Unlicense * This file was automatically generated by https://github.com/yt-dlp/ejs */ var jsc = (function (meriyah, astring) { 'use strict'; function matchesStructure(obj, structure) { if (Array.isArray(structure)) { if (!Array.isArray(obj)) { return false; } return ( structure.length === obj.length && structure.every((value, index) => matchesStructure(obj[index], value)) ); } if (typeof structure === 'object') { if (!obj) { return !structure; } if ('or' in structure) { return structure.or.some((node) => matchesStructure(obj, node)); } if ('anykey' in structure && Array.isArray(structure.anykey)) { const haystack = Array.isArray(obj) ? obj : Object.values(obj); return structure.anykey.every((value) => haystack.some((el) => matchesStructure(el, value)), ); } for (const [key, value] of Object.entries(structure)) { if (!matchesStructure(obj[key], value)) { return false; } } return true; } return structure === obj; } function isOneOf(value, ...of) { return of.includes(value); } function _optionalChain$2(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } const nsigExpression = { type: 'VariableDeclaration', kind: 'var', declarations: [ { type: 'VariableDeclarator', init: { type: 'CallExpression', callee: { type: 'Identifier' }, arguments: [ { type: 'Literal' }, { type: 'CallExpression', callee: { type: 'Identifier', name: 'decodeURIComponent' }, }, ], }, }, ], }; const logicalExpression = { type: 'ExpressionStatement', expression: { type: 'LogicalExpression', left: { type: 'Identifier' }, right: { type: 'SequenceExpression', expressions: [ { type: 'AssignmentExpression', left: { type: 'Identifier' }, operator: '=', right: { type: 'CallExpression', callee: { type: 'Identifier' }, arguments: { or: [ [ { type: 'Literal' }, { type: 'CallExpression', callee: { type: 'Identifier', name: 'decodeURIComponent', }, arguments: [{ type: 'Identifier' }], optional: false, }, ], [ { type: 'CallExpression', callee: { type: 'Identifier', name: 'decodeURIComponent', }, arguments: [{ type: 'Identifier' }], optional: false, }, ], ], }, optional: false, }, }, { type: 'CallExpression' }, ], }, operator: '&&', }, }; const identifier$1 = { or: [ { type: 'ExpressionStatement', expression: { type: 'AssignmentExpression', operator: '=', left: { type: 'Identifier' }, right: { type: 'FunctionExpression', params: [{}, {}, {}] }, }, }, { type: 'FunctionDeclaration', params: [{}, {}, {}] }, { type: 'VariableDeclaration', declarations: { anykey: [ { type: 'VariableDeclarator', init: { type: 'FunctionExpression', params: [{}, {}, {}] }, }, ], }, }, ], }; function extract$1(node) { if (!matchesStructure(node, identifier$1)) { return null; } let block; if ( node.type === 'ExpressionStatement' && node.expression.type === 'AssignmentExpression' && node.expression.right.type === 'FunctionExpression' ) { block = node.expression.right.body; } else if (node.type === 'VariableDeclaration') { for (const decl of node.declarations) { if ( decl.type === 'VariableDeclarator' && _optionalChain$2([ decl, 'access', (_) => _.init, 'optionalAccess', (_2) => _2.type, ]) === 'FunctionExpression' && _optionalChain$2([ decl, 'access', (_3) => _3.init, 'optionalAccess', (_4) => _4.params, 'access', (_5) => _5.length, ]) === 3 ) { block = decl.init.body; break; } } } else if (node.type === 'FunctionDeclaration') { block = node.body; } else { return null; } const relevantExpression = _optionalChain$2([ block, 'optionalAccess', (_6) => _6.body, 'access', (_7) => _7.at, 'call', (_8) => _8(-2), ]); let call = null; if (matchesStructure(relevantExpression, logicalExpression)) { if ( _optionalChain$2([ relevantExpression, 'optionalAccess', (_9) => _9.type, ]) !== 'ExpressionStatement' || relevantExpression.expression.type !== 'LogicalExpression' || relevantExpression.expression.right.type !== 'SequenceExpression' || relevantExpression.expression.right.expressions[0].type !== 'AssignmentExpression' || relevantExpression.expression.right.expressions[0].right.type !== 'CallExpression' ) { return null; } call = relevantExpression.expression.right.expressions[0].right; } else if ( _optionalChain$2([ relevantExpression, 'optionalAccess', (_10) => _10.type, ]) === 'IfStatement' && relevantExpression.consequent.type === 'BlockStatement' ) { for (const n of relevantExpression.consequent.body) { if (!matchesStructure(n, nsigExpression)) { continue; } if ( n.type !== 'VariableDeclaration' || _optionalChain$2([ n, 'access', (_11) => _11.declarations, 'access', (_12) => _12[0], 'access', (_13) => _13.init, 'optionalAccess', (_14) => _14.type, ]) !== 'CallExpression' ) { continue; } call = n.declarations[0].init; break; } } if (call === null) { return null; } return { type: 'ArrowFunctionExpression', params: [{ type: 'Identifier', name: 'sig' }], body: { type: 'CallExpression', callee: { type: 'Identifier', name: call.callee.name }, arguments: call.arguments.length === 1 ? [{ type: 'Identifier', name: 'sig' }] : [call.arguments[0], { type: 'Identifier', name: 'sig' }], optional: false, }, async: false, expression: false, generator: false, }; } function _optionalChain$1(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } const identifier = { or: [ { type: 'VariableDeclaration', kind: 'var', declarations: { anykey: [ { type: 'VariableDeclarator', id: { type: 'Identifier' }, init: { type: 'ArrayExpression', elements: [{ type: 'Identifier' }], }, }, ], }, }, { type: 'ExpressionStatement', expression: { type: 'AssignmentExpression', left: { type: 'Identifier' }, operator: '=', right: { type: 'ArrayExpression', elements: [{ type: 'Identifier' }], }, }, }, ], }; const catchBlockBody = [ { type: 'ReturnStatement', argument: { type: 'BinaryExpression', left: { type: 'MemberExpression', object: { type: 'Identifier' }, computed: true, property: { type: 'Literal' }, optional: false, }, right: { type: 'Identifier' }, operator: '+', }, }, ]; function extract(node) { if (!matchesStructure(node, identifier)) { let name = null; let block = null; switch (node.type) { case 'ExpressionStatement': { if ( node.expression.type === 'AssignmentExpression' && node.expression.left.type === 'Identifier' && node.expression.right.type === 'FunctionExpression' && node.expression.right.params.length === 1 ) { name = node.expression.left.name; block = node.expression.right.body; } break; } case 'FunctionDeclaration': { if (node.params.length === 1) { name = _optionalChain$1([ node, 'access', (_) => _.id, 'optionalAccess', (_2) => _2.name, ]); block = node.body; } break; } } if (!block || !name) { return null; } const tryNode = block.body.at(-2); if ( _optionalChain$1([tryNode, 'optionalAccess', (_3) => _3.type]) !== 'TryStatement' || _optionalChain$1([ tryNode, 'access', (_4) => _4.handler, 'optionalAccess', (_5) => _5.type, ]) !== 'CatchClause' ) { return null; } const catchBody = tryNode.handler.body.body; if (matchesStructure(catchBody, catchBlockBody)) { return makeSolverFuncFromName(name); } return null; } if (node.type === 'VariableDeclaration') { for (const declaration of node.declarations) { if ( declaration.type !== 'VariableDeclarator' || !declaration.init || declaration.init.type !== 'ArrayExpression' || declaration.init.elements.length !== 1 ) { continue; } const [firstElement] = declaration.init.elements; if (firstElement && firstElement.type === 'Identifier') { return makeSolverFuncFromName(firstElement.name); } } } else if (node.type === 'ExpressionStatement') { const expr = node.expression; if ( expr.type === 'AssignmentExpression' && expr.left.type === 'Identifier' && expr.operator === '=' && expr.right.type === 'ArrayExpression' && expr.right.elements.length === 1 ) { const [firstElement] = expr.right.elements; if (firstElement && firstElement.type === 'Identifier') { return makeSolverFuncFromName(firstElement.name); } } } return null; } function makeSolverFuncFromName(name) { return { type: 'ArrowFunctionExpression', params: [{ type: 'Identifier', name: 'n' }], body: { type: 'CallExpression', callee: { type: 'Identifier', name: name }, arguments: [{ type: 'Identifier', name: 'n' }], optional: false, }, async: false, expression: false, generator: false, }; } const setupNodes = meriyah.parse( `\nif (typeof globalThis.XMLHttpRequest === "undefined") {\n globalThis.XMLHttpRequest = { prototype: {} };\n}\nconst window = Object.create(null);\nif (typeof URL === "undefined") {\n window.location = {\n hash: "",\n host: "www.youtube.com",\n hostname: "www.youtube.com",\n href: "https://www.youtube.com/watch?v=yt-dlp-wins",\n origin: "https://www.youtube.com",\n password: "",\n pathname: "/watch",\n port: "",\n protocol: "https:",\n search: "?v=yt-dlp-wins",\n username: "",\n };\n} else {\n window.location = new URL("https://www.youtube.com/watch?v=yt-dlp-wins");\n}\nif (typeof globalThis.document === "undefined") {\n globalThis.document = Object.create(null);\n}\nif (typeof globalThis.navigator === "undefined") {\n globalThis.navigator = Object.create(null);\n}\nif (typeof globalThis.self === "undefined") {\n globalThis.self = globalThis;\n}\n`, ).body; function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } function preprocessPlayer(data) { const ast = meriyah.parse(data); const body = ast.body; const block = (() => { switch (body.length) { case 1: { const func = body[0]; if ( _optionalChain([func, 'optionalAccess', (_) => _.type]) === 'ExpressionStatement' && func.expression.type === 'CallExpression' && func.expression.callee.type === 'MemberExpression' && func.expression.callee.object.type === 'FunctionExpression' ) { return func.expression.callee.object.body; } break; } case 2: { const func = body[1]; if ( _optionalChain([func, 'optionalAccess', (_2) => _2.type]) === 'ExpressionStatement' && func.expression.type === 'CallExpression' && func.expression.callee.type === 'FunctionExpression' ) { const block = func.expression.callee.body; block.body.splice(0, 1); return block; } break; } } throw 'unexpected structure'; })(); const found = { n: [], sig: [] }; const plainExpressions = block.body.filter((node) => { const n = extract(node); if (n) { found.n.push(n); } const sig = extract$1(node); if (sig) { found.sig.push(sig); } if (node.type === 'ExpressionStatement') { if (node.expression.type === 'AssignmentExpression') { return true; } return node.expression.type === 'Literal'; } return true; }); block.body = plainExpressions; for (const [name, options] of Object.entries(found)) { const unique = new Set(options.map((x) => JSON.stringify(x))); if (unique.size !== 1) { const message = `found ${unique.size} ${name} function possibilities`; throw ( message + (unique.size ? `: ${options.map((x) => astring.generate(x)).join(', ')}` : '') ); } plainExpressions.push({ type: 'ExpressionStatement', expression: { type: 'AssignmentExpression', operator: '=', left: { type: 'MemberExpression', computed: false, object: { type: 'Identifier', name: '_result' }, property: { type: 'Identifier', name: name }, }, right: options[0], }, }); } ast.body.splice(0, 0, ...setupNodes); return astring.generate(ast); } function getFromPrepared(code) { const resultObj = { n: null, sig: null }; Function('_result', code)(resultObj); return resultObj; } function main(input) { const preprocessedPlayer = input.type === 'player' ? preprocessPlayer(input.player) : input.preprocessed_player; const solvers = getFromPrepared(preprocessedPlayer); const responses = input.requests.map((input) => { if (!isOneOf(input.type, 'n', 'sig')) { return { type: 'error', error: `Unknown request type: ${input.type}` }; } const solver = solvers[input.type]; if (!solver) { return { type: 'error', error: `Failed to extract ${input.type} function`, }; } try { return { type: 'result', data: Object.fromEntries( input.challenges.map((challenge) => [challenge, solver(challenge)]), ), }; } catch (error) { return { type: 'error', error: error instanceof Error ? `${error.message}\n${error.stack}` : `${error}`, }; } }); const output = { type: 'result', responses: responses }; if (input.type === 'player' && input.output_preprocessed) { output.preprocessed_player = preprocessedPlayer; } return output; } return main; })(meriyah, astring); ================================================ FILE: app/src/main/kotlin/com/dpi/ActivityLifecycleManager.kt ================================================ package com.dpi import android.annotation.SuppressLint import android.app.Activity import android.app.Application import android.os.Bundle import android.os.Handler import android.os.Looper import timber.log.Timber import java.util.Collections import java.util.concurrent.ConcurrentHashMap /** * Manages activity lifecycle events and associated logic. * Provides hooks for monitoring and responding to activity lifecycle changes. */ abstract class ActivityLifecycleManager : BaseLifecycleContentProvider() { private val activeActivities: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val handler = Handler(Looper.getMainLooper()) private val activityTimerRunnable: Runnable = object : Runnable { override fun run() { try { activeActivities.forEach { activity -> try { onActivityTimer(activity) } catch (e: Exception) { Timber.tag(TAG).w(e, "Error in activity timer") } } handler.postDelayed(this, activityTimerDelayMillis.toLong()) } catch (e: Exception) { Timber.tag(TAG).w(e, "Error in activity timer runnable") } } } protected open val activityTimerDelayMillis: Int get() = 3000 protected open fun onActivityTimer(activity: Activity) {} override fun onCreate(): Boolean { val application = getApplication() ?: return true if (!onInit(application)) { return true } application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { this@ActivityLifecycleManager.onActivityCreated(activity) } override fun onActivityStarted(activity: Activity) { this@ActivityLifecycleManager.onActivityStarted(activity) } override fun onActivityResumed(activity: Activity) { activeActivities.add(activity) handler.removeCallbacksAndMessages(null) handler.post(activityTimerRunnable) this@ActivityLifecycleManager.onActivityResumed(activity) } override fun onActivityPaused(activity: Activity) { activeActivities.remove(activity) this@ActivityLifecycleManager.onActivityPaused(activity) } override fun onActivityStopped(activity: Activity) { this@ActivityLifecycleManager.onActivityStopped(activity) } override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) { this@ActivityLifecycleManager.onActivityDestroyed(activity) } }) return true } protected open fun onInit(application: Application): Boolean = true protected open fun onActivityCreated(activity: Activity) {} protected open fun onActivityStarted(activity: Activity) {} protected open fun onActivityResumed(activity: Activity) {} protected open fun onActivityPaused(activity: Activity) {} protected open fun onActivityStopped(activity: Activity) {} protected open fun onActivityDestroyed(activity: Activity) {} companion object { private val TAG = ActivityLifecycleManager::class.java.simpleName @SuppressLint("PrivateApi") private fun getApplication(): Application? { return try { val activityThreadClass = Class.forName("android.app.ActivityThread") val activityThread = activityThreadClass .getMethod("currentActivityThread") .invoke(null) activityThreadClass .getMethod("getApplication") .invoke(activityThread) as? Application } catch (e: Exception) { Timber.tag("AppUtils").w(e, "Failed to get Application instance") null } } } } ================================================ FILE: app/src/main/kotlin/com/dpi/BaseLifecycleContentProvider.kt ================================================ package com.dpi import android.content.ContentProvider import android.content.ContentValues import android.database.Cursor import android.net.Uri /** * Base class for lifecycle management ContentProvider with default implementations. * This class exists solely to leverage ContentProvider's early initialization lifecycle. */ abstract class BaseLifecycleContentProvider : ContentProvider() { override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 override fun getType(uri: Uri): String? = null override fun insert(uri: Uri, values: ContentValues?): Uri? = null override fun onCreate(): Boolean = true override fun query( uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String? ): Cursor? = null override fun update( uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array? ): Int = 0 } ================================================ FILE: app/src/main/kotlin/com/dpi/DensityConfiguration.kt ================================================ package com.dpi import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.res.Configuration import android.content.res.Resources import android.util.Log import timber.log.Timber import kotlin.math.roundToInt /** * Configuration class for adjusting screen density dynamically. * Applies density scaling to the entire application and maintains it across activity lifecycle events. */ internal class DensityConfiguration( private val densityScale: Float ) : ActivityLifecycleManager() { private var originalDensityDpi: Int = 0 /** * Applies the density scaling to the application context. * This method should be called once during initialization. */ @SuppressLint("LogNotTimber") fun applyDensityScaling(context: Context) { if (densityScale == 1.0f) return try { onCreate() val resources = context.resources val config = Configuration(resources.configuration) originalDensityDpi = config.densityDpi updateDensityDpi(config, resources) } catch (e: Exception) { Log.w(TAG, "Failed to apply configuration", e) } } /** * Updates the density DPI in the configuration and applies it to resources. */ private fun updateDensityDpi(config: Configuration, resources: Resources) { val newDensityDpi = (originalDensityDpi * densityScale).roundToInt() config.densityDpi = newDensityDpi Timber.tag(TAG).i("Updated densityDpi to: $newDensityDpi") @Suppress("DEPRECATION") resources.updateConfiguration(config, resources.displayMetrics) } /** * Reapply density scaling when an activity is created. */ override fun onActivityCreated(activity: Activity) { applyDensityToActivity(activity) } /** * Reapply density scaling when an activity is resumed. */ override fun onActivityResumed(activity: Activity) { applyDensityToActivity(activity) } /** * Reapply density scaling when an activity is started. */ override fun onActivityStarted(activity: Activity) { applyDensityToActivity(activity) } /** * Applies the density configuration to a specific activity's resources. */ private fun applyDensityToActivity(activity: Activity) { try { updateDensityDpi(activity.resources.configuration, activity.resources) } catch (e: Exception) { Timber.tag(TAG).w(e, "Failed to update density for activity") } } companion object { private val TAG = DensityConfiguration::class.java.simpleName } } ================================================ FILE: app/src/main/kotlin/com/dpi/DensityScaler.kt ================================================ package com.dpi import android.content.Context import timber.log.Timber /** * DensityScaler - Main entry point for screen density scaling. * * Reads scale factor from user preferences with default of 1.0f (100% native). * * Supported scale factors: * - 1.0f (100%) - Native density (default) * - 0.75f (75%) - Compact * - 0.65f (65%) - Very Compact * - 0.55f (55%) - Ultra Compact */ class DensityScaler : BaseLifecycleContentProvider() { override fun onCreate(): Boolean { val context = context ?: return false val scaleFactor = getScaleFactorFromPreferences(context) DensityConfiguration(scaleFactor).applyDensityScaling(context) return true } companion object { private const val PREFS_NAME = "metrolist_settings" private const val KEY_DENSITY_SCALE = "density_scale_factor" private const val DEFAULT_SCALE_FACTOR = 1.0f /** * Reads the density scale factor from SharedPreferences. * Uses SharedPreferences instead of DataStore for synchronous access during ContentProvider initialization. */ private fun getScaleFactorFromPreferences(context: Context): Float { return try { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.getFloat(KEY_DENSITY_SCALE, DEFAULT_SCALE_FACTOR) } catch (e: Exception) { Timber.tag("DensityScaler").w(e, "Failed to read scale factor from preferences") DEFAULT_SCALE_FACTOR } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/App.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.os.Build import android.widget.Toast import androidx.datastore.preferences.core.edit import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader import coil3.disk.DiskCache import coil3.disk.directory import coil3.memory.MemoryCache import coil3.request.CachePolicy import coil3.request.allowHardware import coil3.request.crossfade import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.YouTubeLocale import com.metrolist.kugou.KuGou import com.metrolist.lastfm.LastFM import com.metrolist.music.BuildConfig import com.metrolist.music.constants.* import com.metrolist.music.di.ApplicationScope import com.metrolist.music.extensions.toEnum import com.metrolist.music.extensions.toInetSocketAddress import com.metrolist.music.utils.CrashHandler import com.metrolist.music.utils.cipher.CipherDeobfuscator import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.reportException import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope 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 kotlinx.coroutines.withContext import okhttp3.Credentials import timber.log.Timber import java.net.Authenticator import java.net.PasswordAuthentication import java.net.Proxy import java.util.Locale import javax.inject.Inject @HiltAndroidApp class App : Application(), SingletonImageLoader.Factory { @Inject @ApplicationScope lateinit var applicationScope: CoroutineScope override fun onCreate() { super.onCreate() // Install crash handler first CrashHandler.install(this) // Initialize cipher deobfuscator for WEB_REMIX streaming CipherDeobfuscator.initialize(this) Timber.plant(Timber.DebugTree()) // تهيئة إعدادات التطبيق عند الإقلاع applicationScope.launch { initializeSettings() observeSettingsChanges() } } private suspend fun initializeSettings() { val settings = dataStore.data.first() val locale = Locale.getDefault() val languageTag = locale.language YouTube.locale = YouTubeLocale( gl = settings[ContentCountryKey]?.takeIf { it != SYSTEM_DEFAULT } ?: locale.country.takeIf { it in CountryCodeToName } ?: "US", hl = settings[ContentLanguageKey]?.takeIf { it != SYSTEM_DEFAULT } ?: locale.language.takeIf { it in LanguageCodeToName } ?: languageTag.takeIf { it in LanguageCodeToName } ?: "en", ) if (languageTag == "zh-TW") { KuGou.useTraditionalChinese = true } // Initialize LastFM with API keys from BuildConfig (GitHub Secrets) LastFM.initialize( apiKey = BuildConfig.LASTFM_API_KEY.takeIf { it.isNotEmpty() } ?: "", secret = BuildConfig.LASTFM_SECRET.takeIf { it.isNotEmpty() } ?: "", ) if (settings[ProxyEnabledKey] == true) { val username = settings[ProxyUsernameKey].orEmpty() val password = settings[ProxyPasswordKey].orEmpty() val type = settings[ProxyTypeKey].toEnum(defaultValue = Proxy.Type.HTTP) if (username.isNotEmpty() || password.isNotEmpty()) { if (type == Proxy.Type.HTTP) { YouTube.proxyAuth = Credentials.basic(username, password) } else { Authenticator.setDefault( object : Authenticator() { override fun getPasswordAuthentication(): PasswordAuthentication = PasswordAuthentication(username, password.toCharArray()) }, ) } } try { settings[ProxyUrlKey]?.let { YouTube.proxy = Proxy(type, it.toInetSocketAddress()) } } catch (e: Exception) { withContext(Dispatchers.Main) { Toast.makeText(this@App, getString(R.string.failed_to_parse_proxy), Toast.LENGTH_SHORT).show() } reportException(e) } } YouTube.useLoginForBrowse = settings[UseLoginForBrowse] ?: true val channel = NotificationChannel( "updates", getString(R.string.update_channel_name), NotificationManager.IMPORTANCE_DEFAULT, ).apply { description = getString(R.string.update_channel_desc) } val nm = getSystemService(NotificationManager::class.java) nm.createNotificationChannel(channel) } private fun observeSettingsChanges() { applicationScope.launch(Dispatchers.IO) { dataStore.data .map { it[VisitorDataKey] } .distinctUntilChanged() .collect { visitorData -> YouTube.visitorData = visitorData?.takeIf { it != "null" } ?: YouTube.visitorData().getOrNull()?.also { newVisitorData -> dataStore.edit { settings -> settings[VisitorDataKey] = newVisitorData } } } } applicationScope.launch(Dispatchers.IO) { dataStore.data .map { it[DataSyncIdKey] } .distinctUntilChanged() .collect { dataSyncId -> YouTube.dataSyncId = dataSyncId?.let { it.takeIf { !it.contains("||") } ?: it.takeIf { it.endsWith("||") }?.substringBefore("||") ?: it.substringAfter("||") } } } applicationScope.launch(Dispatchers.IO) { dataStore.data .map { it[InnerTubeCookieKey] } .distinctUntilChanged() .collect { cookie -> try { YouTube.cookie = cookie } catch (e: Exception) { Timber.e(e, "Could not parse cookie. Clearing existing cookie.") forgetAccount(this@App) } } } applicationScope.launch(Dispatchers.IO) { dataStore.data .map { it[LastFMSessionKey] } .distinctUntilChanged() .collect { session -> try { LastFM.sessionKey = session } catch (e: Exception) { Timber.e("Error while loading last.fm session key. %s", e.message) } } } applicationScope.launch(Dispatchers.IO) { dataStore.data .map { Triple(it[ContentCountryKey], it[ContentLanguageKey], it[AppLanguageKey]) } .distinctUntilChanged() .collect { (contentCountry, contentLanguage, appLanguage) -> val systemLocale = Locale.getDefault() val effectiveAppLocale = appLanguage ?.takeUnless { it == SYSTEM_DEFAULT } ?.let { Locale.forLanguageTag(it) } ?: systemLocale YouTube.locale = YouTubeLocale( gl = contentCountry?.takeIf { it != SYSTEM_DEFAULT } ?: effectiveAppLocale.country.takeIf { it in CountryCodeToName } ?: systemLocale.country.takeIf { it in CountryCodeToName } ?: "US", hl = contentLanguage?.takeIf { it != SYSTEM_DEFAULT } ?: effectiveAppLocale.toLanguageTag().takeIf { it in LanguageCodeToName } ?: effectiveAppLocale.language.takeIf { it in LanguageCodeToName } ?: "en", ) } } } override fun newImageLoader(context: PlatformContext): ImageLoader { val cacheSize = runBlocking { dataStore.data.map { it[MaxImageCacheSizeKey] ?: 512 }.first() } return ImageLoader .Builder(this) .apply { crossfade(true) allowHardware(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) // Memory cache for fast image loading (prevents network requests on recomposition) memoryCache { MemoryCache .Builder() .maxSizePercent(context, 0.25) .build() } if (cacheSize == 0) { diskCachePolicy(CachePolicy.DISABLED) } else { diskCache( DiskCache .Builder() .directory(cacheDir.resolve("coil")) .maxSizeBytes(cacheSize * 1024 * 1024L) .build(), ) // Allow reading from disk cache as fallback when network is unavailable networkCachePolicy(CachePolicy.ENABLED) } }.build() } companion object { suspend fun forgetAccount(context: Context) { Timber.d("forgetAccount: Starting logout process") // Clear DataStore preferences Timber.d("forgetAccount: Clearing DataStore preferences") context.dataStore.edit { settings -> settings.remove(InnerTubeCookieKey) settings.remove(VisitorDataKey) settings.remove(DataSyncIdKey) settings.remove(AccountNameKey) settings.remove(AccountEmailKey) settings.remove(AccountChannelHandleKey) } Timber.d("forgetAccount: DataStore preferences cleared") // Immediately clear YouTube object's auth state Timber.d("forgetAccount: Clearing YouTube object auth state") Timber.d( "forgetAccount: Before - cookie=${YouTube.cookie?.take( 50, )}, visitorData=${YouTube.visitorData?.take(20)}, dataSyncId=${YouTube.dataSyncId?.take(20)}", ) YouTube.cookie = null YouTube.visitorData = null YouTube.dataSyncId = null Timber.d( "forgetAccount: After - cookie=${YouTube.cookie}, visitorData=${YouTube.visitorData}, dataSyncId=${YouTube.dataSyncId}", ) // Clear WebView cookies to prevent auto-relogin Timber.d("forgetAccount: Clearing WebView CookieManager") withContext(Dispatchers.Main) { android.webkit.CookieManager.getInstance().apply { removeAllCookies { removed -> Timber.d("forgetAccount: CookieManager.removeAllCookies callback: removed=$removed") } flush() } } Timber.d("forgetAccount: Logout process complete") } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/MainActivity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music import android.Manifest import android.annotation.SuppressLint import android.app.PendingIntent import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.os.IBinder import android.view.View import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState 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.foundation.background import androidx.compose.foundation.isSystemInDarkTheme 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.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.displayCutout 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.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox 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.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults 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.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf 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.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalWindowInfo 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.compose.ui.util.fastAny import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat 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.datastore.preferences.core.edit import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.coroutineScope import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import coil3.compose.AsyncImage import coil3.imageLoader import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.request.crossfade import coil3.toBitmap import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.constants.AppBarHeight import com.metrolist.music.constants.AppLanguageKey import com.metrolist.music.constants.CheckForUpdatesKey import com.metrolist.music.constants.DarkModeKey import com.metrolist.music.constants.DefaultOpenTabKey import com.metrolist.music.constants.DisableScreenshotKey import com.metrolist.music.constants.DynamicThemeKey import com.metrolist.music.constants.EnableHighRefreshRateKey import com.metrolist.music.constants.LastSeenVersionKey import com.metrolist.music.constants.ListenTogetherInTopBarKey import com.metrolist.music.constants.ListenTogetherUsernameKey import com.metrolist.music.constants.MiniPlayerBottomSpacing import com.metrolist.music.constants.MiniPlayerHeight import com.metrolist.music.constants.NavigationBarAnimationSpec import com.metrolist.music.constants.NavigationBarHeight import com.metrolist.music.constants.PauseListenHistoryKey import com.metrolist.music.constants.PauseSearchHistoryKey import com.metrolist.music.constants.PureBlackKey import com.metrolist.music.constants.SYSTEM_DEFAULT import com.metrolist.music.constants.SelectedThemeColorKey import com.metrolist.music.constants.SlimNavBarHeight import com.metrolist.music.constants.SlimNavBarKey import com.metrolist.music.constants.StopMusicOnTaskClearKey import com.metrolist.music.constants.UpdateNotificationsEnabledKey import com.metrolist.music.constants.UseNewMiniPlayerDesignKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.SearchHistory import com.metrolist.music.extensions.toEnum import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.DownloadUtil import com.metrolist.music.playback.MusicService import com.metrolist.music.playback.MusicService.MusicBinder import com.metrolist.music.playback.PlayerConnection import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.AccountSettingsDialog import com.metrolist.music.ui.component.AppNavigationBar import com.metrolist.music.ui.component.AppNavigationRail import com.metrolist.music.ui.component.BottomSheetMenu import com.metrolist.music.ui.component.BottomSheetPage import com.metrolist.music.ui.component.LocalBottomSheetPageState import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.rememberBottomSheetState import com.metrolist.music.ui.component.shimmer.ShimmerTheme import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.player.BottomSheetPlayer import com.metrolist.music.ui.screens.Screens import com.metrolist.music.ui.screens.navigationBuilder import com.metrolist.music.ui.screens.settings.ChangelogScreen import com.metrolist.music.ui.screens.settings.DarkMode import com.metrolist.music.ui.screens.settings.NavigationTab import com.metrolist.music.ui.theme.ColorSaver import com.metrolist.music.ui.theme.DefaultThemeColor import com.metrolist.music.ui.theme.MetrolistTheme import com.metrolist.music.ui.theme.extractThemeColor import com.metrolist.music.ui.utils.appBarScrollBehavior import com.metrolist.music.ui.utils.resetHeightOffset import com.metrolist.music.utils.SyncUtils import com.metrolist.music.utils.Updater import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.utils.reportException import com.metrolist.music.utils.setAppLocale import com.metrolist.music.viewmodels.HomeViewModel 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.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber import java.net.URLDecoder import java.net.URLEncoder import java.util.Locale import javax.inject.Inject @Suppress("DEPRECATION", "ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") @AndroidEntryPoint class MainActivity : ComponentActivity() { companion object { private const val ACTION_SEARCH = "com.metrolist.music.action.SEARCH" private const val ACTION_LIBRARY = "com.metrolist.music.action.LIBRARY" const val ACTION_RECOGNITION = "com.metrolist.music.action.RECOGNITION" const val EXTRA_AUTO_START_RECOGNITION = "auto_start_recognition" } @Inject lateinit var database: MusicDatabase @Inject lateinit var downloadUtil: DownloadUtil @Inject lateinit var syncUtils: SyncUtils @Inject lateinit var listenTogetherManager: com.metrolist.music.listentogether.ListenTogetherManager private lateinit var navController: NavHostController private var pendingIntent: Intent? = null private var latestVersionName by mutableStateOf(BuildConfig.VERSION_NAME) private var playerConnection by mutableStateOf(null) private var isServiceBound = false private val serviceConnection = object : ServiceConnection { override fun onServiceConnected( name: ComponentName?, service: IBinder?, ) { if (service is MusicBinder) { try { playerConnection = PlayerConnection(this@MainActivity, service, database, lifecycleScope) Timber.tag("MainActivity").d("PlayerConnection created successfully") // Connect Listen Together manager to player listenTogetherManager.setPlayerConnection(playerConnection) } catch (e: Exception) { Timber.tag("MainActivity").e(e, "Failed to create PlayerConnection") // Retry after a delay of 500ms lifecycleScope.launch { delay(500) try { playerConnection = PlayerConnection(this@MainActivity, service, database, lifecycleScope) listenTogetherManager.setPlayerConnection(playerConnection) } catch (e2: Exception) { Timber.tag("MainActivity").e(e2, "Failed to create PlayerConnection on retry") } } } } } override fun onServiceDisconnected(name: ComponentName?) { // Disconnect Listen Together manager listenTogetherManager.setPlayerConnection(null) playerConnection?.dispose() playerConnection = null } } private fun safeUnbindService(source: String) { if (!isServiceBound) return try { unbindService(serviceConnection) } catch (e: IllegalArgumentException) { Timber.tag("MainActivity").w(e, "Service was not bound when attempting to unbind in $source") } finally { isServiceBound = false listenTogetherManager.setPlayerConnection(null) playerConnection?.dispose() playerConnection = null } } override fun onStart() { super.onStart() // Request notification permission on Android 13+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1000) } } // Explicitly start the service so it becomes an "explicitly started" service. // Without this, the service only exists while a client is bound (BIND_AUTO_CREATE). // When onStop() releases the binding (e.g. screen off, app backgrounded), Media3's // MediaNotificationManager tries to call startForegroundService() to keep the service // alive — but this is blocked on Android 12+ when the app is in the background, // causing ForegroundServiceStartNotAllowedException. Starting the service explicitly // here ensures it persists independently of binding state, so Media3 never needs to // re-start it from a background context. startService(Intent(this, MusicService::class.java)) bindService( Intent(this, MusicService::class.java), serviceConnection, BIND_AUTO_CREATE, ) isServiceBound = true } override fun onStop() { safeUnbindService("onStop()") super.onStop() } override fun onDestroy() { super.onDestroy() if (dataStore.get(StopMusicOnTaskClearKey, false) && playerConnection?.isPlaying?.value == true && isFinishing ) { stopService(Intent(this, MusicService::class.java)) } safeUnbindService("onDestroy()") } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (::navController.isInitialized) { handleDeepLinkIntent(intent, navController) } else { pendingIntent = intent } } @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.decorView.layoutDirection = View.LAYOUT_DIRECTION_LTR WindowCompat.setDecorFitsSystemWindows(window, false) // Initialize Listen Together manager listenTogetherManager.initialize() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { val locale = dataStore[AppLanguageKey] ?.takeUnless { it == SYSTEM_DEFAULT } ?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault() setAppLocale(this, locale) } 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) } } } setContent { MetrolistApp( latestVersionName = latestVersionName, onLatestVersionNameChange = { latestVersionName = it }, playerConnection = playerConnection, database = database, downloadUtil = downloadUtil, syncUtils = syncUtils, ) } } @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MetrolistApp( latestVersionName: String, onLatestVersionNameChange: (String) -> Unit, playerConnection: PlayerConnection?, database: MusicDatabase, downloadUtil: DownloadUtil, syncUtils: SyncUtils, ) { val checkForUpdates by rememberPreference(CheckForUpdatesKey, defaultValue = true) if (BuildConfig.UPDATER_AVAILABLE) { LaunchedEffect(checkForUpdates) { if (checkForUpdates) { withContext(Dispatchers.IO) { val updatesEnabled = dataStore.get(CheckForUpdatesKey, true) val notifEnabled = dataStore.get(UpdateNotificationsEnabledKey, true) if (!updatesEnabled) return@withContext Updater.checkForUpdate().onSuccess { (releaseInfo, hasUpdate) -> if (releaseInfo != null) { onLatestVersionNameChange(releaseInfo.versionName) if (hasUpdate && notifEnabled) { val downloadUrl = Updater.getDownloadUrlForCurrentVariant(releaseInfo) if (downloadUrl != null) { val intent = Intent(Intent.ACTION_VIEW, downloadUrl.toUri()) val flags = PendingIntent.FLAG_UPDATE_CURRENT or (PendingIntent.FLAG_IMMUTABLE) val pending = PendingIntent.getActivity(this@MainActivity, 1001, intent, flags) val notif = NotificationCompat .Builder(this@MainActivity, "updates") .setSmallIcon(R.drawable.update) .setContentTitle(getString(R.string.update_available_title)) .setContentText(releaseInfo.versionName) .setContentIntent(pending) .setAutoCancel(true) .build() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ) { NotificationManagerCompat.from(this@MainActivity).notify(1001, notif) } } } } } } } else { onLatestVersionNameChange(BuildConfig.VERSION_NAME) } } } val enableDynamicTheme by rememberPreference(DynamicThemeKey, defaultValue = true) val enableHighRefreshRate by rememberPreference(EnableHighRefreshRateKey, defaultValue = true) LaunchedEffect(enableHighRefreshRate) { val window = this@MainActivity.window if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val layoutParams = window.attributes if (enableHighRefreshRate) { layoutParams.preferredDisplayModeId = 0 } else { val modes = window.windowManager.defaultDisplay.supportedModes val mode60 = modes.firstOrNull { kotlin.math.abs(it.refreshRate - 60f) < 1f } ?: modes.minByOrNull { kotlin.math.abs(it.refreshRate - 60f) } if (mode60 != null) { layoutParams.preferredDisplayModeId = mode60.modeId } } window.attributes = layoutParams } else { val params = window.attributes if (enableHighRefreshRate) { params.preferredRefreshRate = 0f } else { params.preferredRefreshRate = 60f } window.attributes = params } } 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 } LaunchedEffect(useDarkTheme) { setSystemBarAppearance(useDarkTheme) } val pureBlackEnabled by rememberPreference(PureBlackKey, defaultValue = false) val pureBlack = remember(pureBlackEnabled, useDarkTheme) { pureBlackEnabled && useDarkTheme } val (selectedThemeColorInt) = rememberPreference(SelectedThemeColorKey, defaultValue = DefaultThemeColor.toArgb()) val selectedThemeColor = Color(selectedThemeColorInt) val showChangelog = rememberSaveable { mutableStateOf(false) } var themeColor by rememberSaveable(stateSaver = ColorSaver) { mutableStateOf(selectedThemeColor) } LaunchedEffect(selectedThemeColor) { if (!enableDynamicTheme) { themeColor = selectedThemeColor } } LaunchedEffect(playerConnection, enableDynamicTheme, selectedThemeColor) { val playerConnection = playerConnection if (!enableDynamicTheme || playerConnection == null) { themeColor = selectedThemeColor return@LaunchedEffect } playerConnection.service.currentMediaMetadata.collectLatest { song -> if (song?.thumbnailUrl != null) { withContext(Dispatchers.IO) { try { val result = imageLoader.execute( ImageRequest .Builder(this@MainActivity) .data(song.thumbnailUrl) .allowHardware(false) .memoryCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .crossfade(false) .build(), ) themeColor = result.image?.toBitmap()?.extractThemeColor() ?: selectedThemeColor } catch (e: Exception) { // Fallback to default on error themeColor = selectedThemeColor } } } else { themeColor = selectedThemeColor } } } MetrolistTheme( darkTheme = useDarkTheme, pureBlack = pureBlack, themeColor = themeColor, ) { BoxWithConstraints( modifier = Modifier .fillMaxSize() .background(if (pureBlack) Color.Black else MaterialTheme.colorScheme.surface), ) { val focusManager = LocalFocusManager.current val density = LocalDensity.current val configuration = LocalWindowInfo.current val cutoutInsets = WindowInsets.displayCutout val windowsInsets = WindowInsets.systemBars val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } val bottomInsetDp = WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() val navController = rememberNavController() LaunchedEffect(Unit) { val lastSeenVersion = dataStore.data.first()[LastSeenVersionKey] ?: "" val currentVersion = BuildConfig.VERSION_NAME if (lastSeenVersion != currentVersion) { showChangelog.value = true } dataStore.edit { settings -> settings[LastSeenVersionKey] = currentVersion } } val homeViewModel: HomeViewModel = hiltViewModel() val accountImageUrl by homeViewModel.accountImageUrl.collectAsState() val navBackStackEntry by navController.currentBackStackEntryAsState() val (previousTab, setPreviousTab) = rememberSaveable { mutableStateOf("home") } val (listenTogetherInTopBar) = rememberPreference(ListenTogetherInTopBarKey, defaultValue = true) val navigationItems = remember(listenTogetherInTopBar) { if (listenTogetherInTopBar) { Screens.MainScreens.filter { it != Screens.ListenTogether } } else { Screens.MainScreens } } val (slimNav) = rememberPreference(SlimNavBarKey, defaultValue = false) val (useNewMiniPlayerDesign) = rememberPreference(UseNewMiniPlayerDesignKey, defaultValue = true) val defaultOpenTab = remember { dataStore[DefaultOpenTabKey].toEnum(defaultValue = NavigationTab.HOME) } val tabOpenedFromShortcut = remember { when (intent?.action) { ACTION_SEARCH -> NavigationTab.LIBRARY ACTION_LIBRARY -> NavigationTab.SEARCH else -> null } } val topLevelScreens = remember { listOf( Screens.Home.route, Screens.Library.route, Screens.ListenTogether.route, "settings", ) } val (query, onQueryChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } val onSearch: (String) -> Unit = remember { { searchQuery -> if (searchQuery.isNotEmpty()) { navController.navigate("search/${URLEncoder.encode(searchQuery, "UTF-8")}") if (dataStore[PauseSearchHistoryKey] != true) { lifecycleScope.launch(Dispatchers.IO) { database.query { insert(SearchHistory(query = searchQuery)) } } } } } } val currentRoute by remember { derivedStateOf { navBackStackEntry?.destination?.route } } val inSearchScreen by remember { derivedStateOf { currentRoute?.startsWith("search/") == true } } val navigationItemRoutes = remember(navigationItems) { navigationItems.map { it.route }.toSet() } val shouldShowNavigationBar = remember(currentRoute, navigationItemRoutes) { currentRoute == null || navigationItemRoutes.contains(currentRoute) || currentRoute!!.startsWith("search/") } val isLandscape = configuration.containerDpSize.width > configuration.containerDpSize.height val showRail = isLandscape && !inSearchScreen val navPadding = if (shouldShowNavigationBar && !showRail) { if (slimNav) SlimNavBarHeight else NavigationBarHeight } else { 0.dp } val navigationBarHeight by animateDpAsState( targetValue = if (shouldShowNavigationBar && !showRail) NavigationBarHeight else 0.dp, animationSpec = NavigationBarAnimationSpec, label = "navBarHeight", ) val playerBottomSheetState = rememberBottomSheetState( dismissedBound = 0.dp, collapsedBound = bottomInset + (if (!showRail && shouldShowNavigationBar) navPadding else 0.dp) + (if (useNewMiniPlayerDesign) MiniPlayerBottomSpacing else 0.dp) + MiniPlayerHeight, expandedBound = maxHeight, ) val playerAwareWindowInsets = remember( bottomInset, shouldShowNavigationBar, playerBottomSheetState.isDismissed, showRail, ) { var bottom = bottomInset if (shouldShowNavigationBar && !showRail) { bottom += NavigationBarHeight } if (!playerBottomSheetState.isDismissed) bottom += MiniPlayerHeight windowsInsets .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) .add(WindowInsets(top = AppBarHeight, bottom = bottom)) } appBarScrollBehavior( canScroll = { !inSearchScreen && (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) }, ) val topAppBarScrollBehavior = appBarScrollBehavior( canScroll = { !inSearchScreen && (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) }, ) // Navigation tracking LaunchedEffect(navBackStackEntry) { if (inSearchScreen) { val searchQuery = withContext(Dispatchers.IO) { val rawQuery = navBackStackEntry?.arguments?.getString("query")!! try { URLDecoder.decode(rawQuery, "UTF-8") } catch (e: IllegalArgumentException) { rawQuery } } onQueryChange( TextFieldValue( searchQuery, TextRange(searchQuery.length), ), ) } else if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { onQueryChange(TextFieldValue()) } // Reset scroll behavior for main navigation items if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { if (navigationItems.fastAny { it.route == previousTab }) { topAppBarScrollBehavior.state.resetHeightOffset() } } topAppBarScrollBehavior.state.resetHeightOffset() // Track previous tab for animations navController.currentBackStackEntry?.destination?.route?.let { setPreviousTab(it) } } 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, listenTogetherInTopBar) { val currentRoute = navBackStackEntry?.destination?.route val isListenTogetherScreen = currentRoute == Screens.ListenTogether.route || currentRoute == "listen_together_from_topbar" shouldShowTopBar = currentRoute in topLevelScreens && currentRoute != "settings" && !(isListenTogetherScreen && listenTogetherInTopBar) } val coroutineScope = rememberCoroutineScope() var sharedSong: SongItem? by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(Unit) { if (pendingIntent != null) { handleRecognitionIntent(pendingIntent!!, navController) handleDeepLinkIntent(pendingIntent!!, navController) pendingIntent = null } else { handleRecognitionIntent(intent, navController) handleDeepLinkIntent(intent, navController) } } DisposableEffect(Unit) { val listener = Consumer { intent -> handleRecognitionIntent(intent, navController) handleDeepLinkIntent(intent, navController) } addOnNewIntentListener(listener) onDispose { removeOnNewIntentListener(listener) } } val currentTitleRes = remember(navBackStackEntry) { when (navBackStackEntry?.destination?.route) { Screens.Home.route -> R.string.home Screens.Search.route -> R.string.search Screens.Library.route -> R.string.filter_library Screens.ListenTogether.route -> R.string.together else -> null } } var showAccountDialog by remember { mutableStateOf(false) } val pauseListenHistory by rememberPreference(PauseListenHistoryKey, defaultValue = false) val eventCount by database.eventCount().collectAsState(initial = 0) val showHistoryButton = remember(pauseListenHistory, eventCount) { !(pauseListenHistory && eventCount == 0) } val baseBg = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer CompositionLocalProvider( LocalDatabase provides database, LocalContentColor provides if (pureBlack) Color.White else contentColorFor(MaterialTheme.colorScheme.surface), LocalPlayerConnection provides playerConnection, LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, LocalDownloadUtil provides downloadUtil, LocalShimmerTheme provides ShimmerTheme, LocalSyncUtils provides syncUtils, LocalListenTogetherManager provides listenTogetherManager, LocalChangelogState provides showChangelog, ) { if (showChangelog.value) { ChangelogScreen(onDismiss = { showChangelog.value = false }) } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { AnimatedVisibility( visible = shouldShowTopBar, enter = fadeIn(animationSpec = tween(durationMillis = 300)), exit = fadeOut(animationSpec = tween(durationMillis = 200)), ) { Row { TopAppBar( title = { Text( text = currentTitleRes?.let { stringResource(it) } ?: "", style = MaterialTheme.typography.titleLarge, ) }, actions = { if (showHistoryButton) { IconButton(onClick = { navController.navigate("history") }) { Icon( painter = painterResource(R.drawable.history), contentDescription = stringResource(R.string.history), ) } } IconButton(onClick = { navController.navigate("stats") }) { Icon( painter = painterResource(R.drawable.stats), contentDescription = stringResource(R.string.stats), ) } if (listenTogetherInTopBar) { IconButton(onClick = { navController.navigate("listen_together_from_topbar") }) { Icon( painter = painterResource(R.drawable.group_outlined), contentDescription = stringResource(R.string.together), ) } } IconButton(onClick = { showAccountDialog = true }) { BadgedBox(badge = { if (latestVersionName != BuildConfig.VERSION_NAME) { Badge() } }) { if (accountImageUrl != null) { AsyncImage( model = accountImageUrl, contentDescription = stringResource(R.string.account), modifier = Modifier .size(24.dp) .clip(CircleShape), ) } else { Icon( painter = painterResource(R.drawable.account), contentDescription = stringResource(R.string.account), modifier = Modifier.size(24.dp), ) } } } }, scrollBehavior = topAppBarScrollBehavior, colors = TopAppBarDefaults.topAppBarColors( containerColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer, scrolledContainerColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer, titleContentColor = MaterialTheme.colorScheme.onSurface, actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, navigationIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, ), modifier = Modifier.windowInsetsPadding( if (showRail) { WindowInsets(left = NavigationBarHeight) .add(cutoutInsets.only(WindowInsetsSides.Start)) } else { cutoutInsets.only(WindowInsetsSides.Start + WindowInsetsSides.End) }, ), ) } } }, bottomBar = { val onNavItemClick: (Screens, Boolean) -> Unit = remember(navController, coroutineScope, topAppBarScrollBehavior, playerBottomSheetState) { { screen: Screens, isSelected: Boolean -> if (playerBottomSheetState.isExpanded) { playerBottomSheetState.collapseSoft() } if (isSelected) { navController.currentBackStackEntry?.savedStateHandle?.set("scrollToTop", true) coroutineScope.launch { topAppBarScrollBehavior.state.resetHeightOffset() } } else { navController.navigate(screen.route) { popUpTo(navController.graph.startDestinationId) { saveState = true } launchSingleTop = true restoreState = true } } } } val onSearchLongClick: () -> Unit = remember(navController) { { navController.navigate("recognition") { launchSingleTop = true } } } // Pre-calculate values for graphicsLayer to avoid reading state during composition val navBarTotalHeight = bottomInset + NavigationBarHeight if (!showRail && currentRoute != "wrapped") { Box { BottomSheetPlayer( state = playerBottomSheetState, navController = navController, pureBlack = pureBlack, ) AppNavigationBar( navigationItems = navigationItems, currentRoute = currentRoute, onItemClick = onNavItemClick, pureBlack = pureBlack, slimNav = slimNav, onSearchLongClick = onSearchLongClick, modifier = Modifier .align(Alignment.BottomCenter) .height(bottomInset + navPadding) // Use graphicsLayer instead of offset to avoid recomposition // graphicsLayer runs during draw phase, not composition phase .graphicsLayer { val navBarHeightPx = navigationBarHeight.toPx() val totalHeightPx = navBarTotalHeight.toPx() translationY = if (navBarHeightPx == 0f) { totalHeightPx } else { // Read progress only during draw phase val progress = playerBottomSheetState.progress.coerceIn(0f, 1f) val slideOffset = totalHeightPx * progress val hideOffset = totalHeightPx * (1 - navBarHeightPx / NavigationBarHeight.toPx()) slideOffset + hideOffset } }, ) Box( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) .height(bottomInsetDp) // Use graphicsLayer for background color changes .graphicsLayer { val progress = playerBottomSheetState.progress alpha = if (progress > 0f || (useNewMiniPlayerDesign && !shouldShowNavigationBar) ) { 0f } else { 1f } }.background(baseBg), ) } } else { if (currentRoute != "wrapped") { BottomSheetPlayer( state = playerBottomSheetState, navController = navController, pureBlack = pureBlack, ) } Box( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) .height(bottomInsetDp) // Use graphicsLayer for background color changes .graphicsLayer { val progress = playerBottomSheetState.progress alpha = if (progress > 0f || (useNewMiniPlayerDesign && !shouldShowNavigationBar)) 0f else 1f }.background(baseBg), ) } }, modifier = Modifier .fillMaxSize() .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), ) { Row(Modifier.fillMaxSize()) { val onRailItemClick: (Screens, Boolean) -> Unit = remember(navController, coroutineScope, topAppBarScrollBehavior, playerBottomSheetState) { { screen: Screens, isSelected: Boolean -> if (playerBottomSheetState.isExpanded) { playerBottomSheetState.collapseSoft() } if (isSelected) { navController.currentBackStackEntry?.savedStateHandle?.set("scrollToTop", true) coroutineScope.launch { topAppBarScrollBehavior.state.resetHeightOffset() } } else { navController.navigate(screen.route) { popUpTo(navController.graph.startDestinationId) { saveState = true } launchSingleTop = true restoreState = true } } } } val onRailSearchLongClick: () -> Unit = remember(navController) { { navController.navigate("recognition") { launchSingleTop = true } } } if (showRail && currentRoute != "wrapped") { AppNavigationRail( navigationItems = navigationItems, currentRoute = currentRoute, onItemClick = onRailItemClick, pureBlack = pureBlack, onSearchLongClick = onRailSearchLongClick, ) } Box(Modifier.weight(1f)) { // NavHost with animations (Material 3 Expressive style) NavHost( navController = navController, startDestination = when (tabOpenedFromShortcut ?: defaultOpenTab) { NavigationTab.HOME -> Screens.Home NavigationTab.LIBRARY -> Screens.Library else -> Screens.Home }.route, // Enter Transition - smoother with smaller offset and longer duration enterTransition = { val currentRouteIndex = navigationItems.indexOfFirst { it.route == targetState.destination.route } val previousRouteIndex = navigationItems.indexOfFirst { it.route == initialState.destination.route } if (currentRouteIndex == -1 || currentRouteIndex > previousRouteIndex) { slideInHorizontally { it / 8 } + fadeIn(tween(200)) } else { slideInHorizontally { -it / 8 } + fadeIn(tween(200)) } }, // Exit Transition - smoother with smaller offset and longer duration exitTransition = { val currentRouteIndex = navigationItems.indexOfFirst { it.route == initialState.destination.route } val targetRouteIndex = navigationItems.indexOfFirst { it.route == targetState.destination.route } if (targetRouteIndex == -1 || targetRouteIndex > currentRouteIndex) { slideOutHorizontally { -it / 8 } + fadeOut(tween(200)) } else { slideOutHorizontally { it / 8 } + fadeOut(tween(200)) } }, // Pop Enter Transition - smoother with smaller offset and longer duration popEnterTransition = { val currentRouteIndex = navigationItems.indexOfFirst { it.route == targetState.destination.route } val previousRouteIndex = navigationItems.indexOfFirst { it.route == initialState.destination.route } if (previousRouteIndex != -1 && previousRouteIndex < currentRouteIndex) { slideInHorizontally { it / 8 } + fadeIn(tween(200)) } else { slideInHorizontally { -it / 8 } + fadeIn(tween(200)) } }, // Pop Exit Transition - smoother with smaller offset and longer duration popExitTransition = { val currentRouteIndex = navigationItems.indexOfFirst { it.route == initialState.destination.route } val targetRouteIndex = navigationItems.indexOfFirst { it.route == targetState.destination.route } if (currentRouteIndex != -1 && currentRouteIndex < targetRouteIndex) { slideOutHorizontally { -it / 8 } + fadeOut(tween(200)) } else { slideOutHorizontally { it / 8 } + fadeOut(tween(200)) } }, modifier = Modifier.nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), ) { navigationBuilder( navController = navController, scrollBehavior = topAppBarScrollBehavior, latestVersionName = latestVersionName, activity = this@MainActivity, snackbarHostState = snackbarHostState, ) } } } } BottomSheetMenu( state = LocalMenuState.current, modifier = Modifier.align(Alignment.BottomCenter), ) BottomSheetPage( state = LocalBottomSheetPageState.current, modifier = Modifier.align(Alignment.BottomCenter), ) if (showAccountDialog) { AccountSettingsDialog( navController = navController, onDismiss = { showAccountDialog = false homeViewModel.refresh() }, latestVersionName = latestVersionName, ) } 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 }, ) } } } } } } } } } /** * Handles the ACTION_RECOGNITION intent sent from the Music Recognizer Widget. * Always navigates to the recognition screen to show the result. */ private fun handleRecognitionIntent( intent: Intent, navController: NavHostController, ) { if (intent.action != ACTION_RECOGNITION) return val autoStart = intent.getBooleanExtra(EXTRA_AUTO_START_RECOGNITION, false) intent.action = null intent.removeExtra(EXTRA_AUTO_START_RECOGNITION) navController.navigate(if (autoStart) "recognition?autoStart=true" else "recognition") { launchSingleTop = true } } private fun handleDeepLinkIntent( intent: Intent, navController: NavHostController, ) { val uri = intent.data ?: intent.extras?.getString(Intent.EXTRA_TEXT)?.toUri() ?: return intent.data = null intent.removeExtra(Intent.EXTRA_TEXT) val coroutineScope = lifecycle.coroutineScope val listenCode = uri.getQueryParameter("code") ?: uri.getQueryParameter("room") ?: uri.pathSegments.getOrNull(1) val isListenLink = uri.pathSegments.firstOrNull() == "listen" || uri.host?.equals("listen", ignoreCase = true) == true if (!listenCode.isNullOrBlank() && isListenLink) { val username = dataStore.get(ListenTogetherUsernameKey, "").ifBlank { "Guest" } listenTogetherManager.joinRoom(listenCode, username) return } when (val path = uri.pathSegments.firstOrNull()) { "playlist" -> { uri.getQueryParameter("list")?.let { playlistId -> if (playlistId.startsWith("OLAK5uy_")) { coroutineScope.launch(Dispatchers.IO) { YouTube .albumSongs(playlistId) .onSuccess { songs -> songs.firstOrNull()?.album?.id?.let { browseId -> withContext(Dispatchers.Main) { 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") } } "search" -> { uri.getQueryParameter("q")?.let { navController.navigate("search/${URLEncoder.encode(it, "UTF-8")}") } } else -> { val videoId = when { path == "watch" -> uri.getQueryParameter("v") uri.host == "youtu.be" -> uri.pathSegments.firstOrNull() else -> null } val playlistId = uri.getQueryParameter("list") if (videoId != null) { coroutineScope.launch(Dispatchers.IO) { YouTube .queue(listOf(videoId), playlistId) .onSuccess { queue -> withContext(Dispatchers.Main) { playerConnection?.playQueue( YouTubeQueue( WatchEndpoint(videoId = queue.firstOrNull()?.id, playlistId = playlistId), queue.firstOrNull()?.toMediaMetadata(), ), ) } }.onFailure { reportException(it) } } } else if (playlistId != null) { coroutineScope.launch(Dispatchers.IO) { YouTube .queue(null, playlistId) .onSuccess { queue -> val firstItem = queue.firstOrNull() withContext(Dispatchers.Main) { playerConnection?.playQueue( YouTubeQueue( WatchEndpoint(videoId = firstItem?.id, playlistId = playlistId), firstItem?.toMediaMetadata(), ), ) } }.onFailure { reportException(it) } } } } } } @SuppressLint("ObsoleteSdkInt") private fun setSystemBarAppearance(isDark: Boolean) { WindowCompat.getInsetsController(window, window.decorView.rootView).apply { isAppearanceLightStatusBars = !isDark isAppearanceLightNavigationBars = !isDark } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { window.statusBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { window.navigationBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() } } } 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") } val LocalListenTogetherManager = staticCompositionLocalOf { null } val LocalChangelogState = staticCompositionLocalOf> { error("No LocalChangelogState provided") } val LocalIsPlayerExpanded = compositionLocalOf { false } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/api/DeepLService.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.api import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.TimeUnit object DeepLService { private val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(90, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() private val JSON = "application/json; charset=utf-8".toMediaType() suspend fun translate( text: String, targetLanguage: String, apiKey: String, formality: String = "default", maxRetries: Int = 3 ): Result> = withContext(Dispatchers.IO) { var currentAttempt = 0 // Validate input if (text.isBlank()) { return@withContext Result.failure(Exception("Input text is empty")) } val lines = text.lines() val lineCount = lines.size // DeepL language codes (uppercase) val deeplLangCode = when (targetLanguage.lowercase()) { "zh", "zh-cn", "zh-hans" -> "ZH" "zh-tw", "zh-hant" -> "ZH" "en", "en-us" -> "EN-US" "en-gb" -> "EN-GB" "pt", "pt-pt" -> "PT-PT" "pt-br" -> "PT-BR" else -> targetLanguage.uppercase().take(2) } // Determine if using free or pro API val baseUrl = if (apiKey.endsWith(":fx")) { "https://api-free.deepl.com/v2/translate" } else { "https://api.deepl.com/v2/translate" } while (currentAttempt < maxRetries) { try { val jsonBody = JSONObject().apply { put("text", JSONArray().apply { lines.forEach { put(it) } }) put("target_lang", deeplLangCode) if (formality != "default") { put("formality", formality) } put("preserve_formatting", true) } val request = Request.Builder() .url(baseUrl) .addHeader("Authorization", "DeepL-Auth-Key ${apiKey.trim()}") .addHeader("Content-Type", "application/json") .post(jsonBody.toString().toRequestBody(JSON)) .build() val response = client.newCall(request).execute() val responseBody = response.body?.string() if (!response.isSuccessful) { // Retry on server errors (5xx) if (response.code >= 500) { currentAttempt++ kotlinx.coroutines.delay(1000L * currentAttempt) continue } val errorMsg = try { JSONObject(responseBody ?: "").optString("message") ?: "HTTP ${response.code}: ${response.message}" } catch (e: Exception) { "HTTP ${response.code}: ${response.message}" } return@withContext Result.failure(Exception("Translation failed: $errorMsg")) } if (responseBody == null) { currentAttempt++ continue } val jsonResponse = JSONObject(responseBody) val translations = jsonResponse.optJSONArray("translations") if (translations != null && translations.length() > 0) { val translatedLines = (0 until translations.length()).map { i -> translations.getJSONObject(i).optString("text", "") } if (translatedLines.size == lineCount) { return@withContext Result.success(translatedLines) } else if (translatedLines.size > lineCount) { return@withContext Result.success(translatedLines.take(lineCount)) } else { val paddedLines = translatedLines.toMutableList() while (paddedLines.size < lineCount) { paddedLines.add("") } return@withContext Result.success(paddedLines) } } } catch (e: Exception) { if (currentAttempt == maxRetries - 1) { return@withContext Result.failure(e) } } currentAttempt++ kotlinx.coroutines.delay(1000L * currentAttempt) } return@withContext Result.failure(Exception("Max retries exceeded")) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/api/MistralService.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.api import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.TimeUnit object MistralService { private val client = OkHttpClient .Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(90, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() private val JSON = "application/json; charset=utf-8".toMediaType() suspend fun translate( text: String, targetLanguage: String, apiKey: String, model: String, mode: String, maxRetries: Int = 3, sourceLanguage: String? = null, customSystemPrompt: String = "", ): Result> = withContext(Dispatchers.IO) { var currentAttempt = 0 // Validate input if (text.isBlank()) { return@withContext Result.failure(Exception("Input text is empty")) } val lines = text.lines() val lineCount = lines.size while (currentAttempt < maxRetries) { try { // Use custom system prompt if provided, otherwise use the default val systemPrompt = if (customSystemPrompt.isNotBlank()) { customSystemPrompt.replace("{lineCount}", lineCount.toString()) } else { """You are a precise lyrics translation assistant. Your output must ALWAYS be a valid JSON array of strings. CRITICAL RULES: 1. Output ONLY a JSON array: ["line1", "line2", "line3"] 2. NO explanations, NO questions, NO additional text 3. Each input line maps to exactly one output line 4. Preserve empty lines as empty strings "" 5. Return EXACTLY $lineCount items in the array 6. If uncertain, provide best approximation but maintain line count""" } val userPrompt = when (mode) { "Romanized" -> { """Romanize/transliterate the following $lineCount lines into simple Latin script using ONLY basic English letters (a-z, A-Z). CRITICAL REQUIREMENTS: - Use ONLY simple ASCII characters (a-z, A-Z, 0-9, basic punctuation) - NO special characters like ā, ī, ū, ñ, ç, etc. - NO diacritics or accent marks - If text is already in Latin script, return it UNCHANGED - For non-Latin scripts (Hindi, Chinese, Japanese, Korean, Cyrillic, etc.), provide simple romanization - DO NOT translate meaning, only convert script to simple English letters - Keep all punctuation and formatting - Preserve line-by-line structure exactly Examples of correct simple romanization: - Sanskrit/Hindi "आ" → "aa" (not "ā") - Japanese "東京" → "toukyou" or "tokyo" (not "tōkyō") - Korean "서울" → "seoul" (not "sŏul") Input ($lineCount lines): $text Output MUST be a JSON array with EXACTLY $lineCount strings using ONLY simple ASCII characters.""" } "Transcribed" -> { """Transcribe/transliterate the following $lineCount lines phonetically into $targetLanguage script. CRITICAL REQUIREMENTS: - Convert the SOUND/PRONUNCIATION of the original text into $targetLanguage script - DO NOT translate the meaning - only represent how the original words SOUND - Use the native script of $targetLanguage (e.g., Devanagari for Hindi, Hangul for Korean, etc.) - Preserve the original pronunciation as closely as possible in the target script - Keep punctuation and formatting - Preserve line-by-line structure exactly - If text is already in $targetLanguage script, return it UNCHANGED Examples: - Japanese "こんにちは" to Hindi → "कोन्निचिवा" (phonetic, not translation) - English "Hello" to Hindi → "हेलो" (phonetic) - Korean "안녕하세요" to Hindi → "अन्न्योंग हासेयो" (phonetic) Input ($lineCount lines): $text Output MUST be a JSON array with EXACTLY $lineCount strings in $targetLanguage script.""" } else -> { """Translate the following $lineCount lines to $targetLanguage. IMPORTANT: - Provide natural, accurate translation - Maintain poetic flow and meaning - Keep punctuation appropriate for target language - Preserve line-by-line structure exactly - For song lyrics, prioritize singability Input ($lineCount lines): $text Output MUST be a JSON array with EXACTLY $lineCount strings.""" } } val messages = JSONArray().apply { // Include system prompt when a custom one is provided if (customSystemPrompt.isNotBlank()) { put( JSONObject().apply { put("role", "system") put("content", systemPrompt) }, ) } put( JSONObject().apply { put("role", "user") put("content", userPrompt) }, ) } val jsonBody = JSONObject().apply { if (model.isNotBlank()) { put("model", model) } else { put("model", "mistral-small-latest") } put("messages", messages) put("temperature", 0.3) // Lower temperature for more consistent output put("max_tokens", lineCount * 100) // Adequate tokens for translation } val request = Request .Builder() .url("https://api.mistral.ai/v1/chat/completions") .apply { if (apiKey.isNotBlank()) { addHeader("Authorization", "Bearer ${apiKey.trim()}") } }.addHeader("Content-Type", "application/json") .post(jsonBody.toString().toRequestBody(JSON)) .build() val response = client.newCall(request).execute() val responseBody = response.body?.string() if (!response.isSuccessful) { // Retry on server errors (5xx) if (response.code >= 500) { currentAttempt++ kotlinx.coroutines.delay(1000L * currentAttempt) continue } val errorMsg = try { JSONObject(responseBody ?: "").optJSONObject("error")?.optString("message") ?: "HTTP ${response.code}: ${response.message}" } catch (e: Exception) { "HTTP ${response.code}: ${response.message}" } return@withContext Result.failure(Exception("Translation failed: $errorMsg")) } if (responseBody == null) { currentAttempt++ continue } val jsonResponse = JSONObject(responseBody) val choices = jsonResponse.optJSONArray("choices") if (choices != null && choices.length() > 0) { val message = choices.getJSONObject(0).optJSONObject("message") var content = message?.optString("content")?.trim() if (!content.isNullOrBlank()) { // Enhanced JSON extraction with multiple fallback strategies var translatedLines: List? = null // Strategy 1: Try direct JSON parsing try { val jsonArray = JSONArray(content) translatedLines = (0 until jsonArray.length()).map { jsonArray.optString(it) } } catch (e: Exception) { // Strategy 2: Extract JSON from markdown code blocks content = content.replace("```json", "").replace("```", "").trim() try { val jsonArray = JSONArray(content) translatedLines = (0 until jsonArray.length()).map { jsonArray.optString(it) } } catch (e2: Exception) { // Strategy 3: Find first [ and last ] val startIdx = content.indexOf('[') val endIdx = content.lastIndexOf(']') if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) { val jsonString = content.substring(startIdx, endIdx + 1) try { val jsonArray = JSONArray(jsonString) translatedLines = (0 until jsonArray.length()).map { jsonArray.optString(it) } } catch (e3: Exception) { // Strategy 4: Manual line-by-line parsing as last resort translatedLines = content .lines() .filter { it.trim().isNotEmpty() } .map { it.trim().removeSurrounding("\"").removeSurrounding("'") } } } } } if (translatedLines != null) { // Validate line count matches if (translatedLines.size == lineCount) { return@withContext Result.success(translatedLines) } else if (translatedLines.size > lineCount) { // If we got more lines, take first N return@withContext Result.success(translatedLines.take(lineCount)) } else { // If we got fewer lines, pad with empty strings val paddedLines = translatedLines.toMutableList() while (paddedLines.size < lineCount) { paddedLines.add("") } return@withContext Result.success(paddedLines) } } } } } catch (e: Exception) { if (currentAttempt == maxRetries - 1) { return@withContext Result.failure(e) } } currentAttempt++ kotlinx.coroutines.delay(1000L * currentAttempt) } return@withContext Result.failure(Exception("Max retries exceeded")) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/api/OpenRouterService.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.api import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.TimeUnit object OpenRouterService { private val client = OkHttpClient .Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(90, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() private val JSON = "application/json; charset=utf-8".toMediaType() suspend fun translate( text: String, targetLanguage: String, apiKey: String, baseUrl: String, model: String, mode: String, maxRetries: Int = 3, sourceLanguage: String? = null, customSystemPrompt: String = "", ): Result> = withContext(Dispatchers.IO) { var currentAttempt = 0 // Validate input if (text.isBlank()) { return@withContext Result.failure(Exception("Input text is empty")) } val lines = text.lines() val lineCount = lines.size while (currentAttempt < maxRetries) { try { // Use custom system prompt if provided, otherwise use the default val systemPrompt = if (customSystemPrompt.isNotBlank()) { customSystemPrompt.replace("{lineCount}", lineCount.toString()) } else { """You are a precise lyrics translation assistant. Your output must ALWAYS be a valid JSON array of strings. CRITICAL RULES: 1. Output ONLY a JSON array: ["line1", "line2", "line3"] 2. NO explanations, NO questions, NO additional text 3. Each input line maps to exactly one output line 4. Preserve empty lines as empty strings "" 5. Return EXACTLY $lineCount items in the array 6. If uncertain, provide best approximation but maintain line count""" } val userPrompt = when (mode) { "Romanized" -> { """Romanize/transliterate the following $lineCount lines into simple Latin script using ONLY basic English letters (a-z, A-Z). CRITICAL REQUIREMENTS: - Use ONLY simple ASCII characters (a-z, A-Z, 0-9, basic punctuation) - NO special characters like ā, ī, ū, ñ, ç, etc. - NO diacritics or accent marks - If text is already in Latin script, return it UNCHANGED - For non-Latin scripts (Hindi, Chinese, Japanese, Korean, Cyrillic, etc.), provide simple romanization - DO NOT translate meaning, only convert script to simple English letters - Keep all punctuation and formatting - Preserve line-by-line structure exactly Examples of correct simple romanization: - Sanskrit/Hindi "आ" → "aa" (not "ā") - Japanese "東京" → "toukyou" or "tokyo" (not "tōkyō") - Korean "서울" → "seoul" (not "sŏul") Input ($lineCount lines): $text Output MUST be a JSON array with EXACTLY $lineCount strings using ONLY simple ASCII characters.""" } "Transcribed" -> { """Transcribe/transliterate the following $lineCount lines phonetically into $targetLanguage script. CRITICAL REQUIREMENTS: - Convert the SOUND/PRONUNCIATION of the original text into $targetLanguage script - DO NOT translate the meaning - only represent how the original words SOUND - Use the native script of $targetLanguage (e.g., Devanagari for Hindi, Hangul for Korean, etc.) - Preserve the original pronunciation as closely as possible in the target script - Keep punctuation and formatting - Preserve line-by-line structure exactly - If text is already in $targetLanguage script, return it UNCHANGED Examples: - Japanese "こんにちは" to Hindi → "कोन्निचिवा" (phonetic, not translation) - English "Hello" to Hindi → "हेलो" (phonetic) - Korean "안녕하세요" to Hindi → "अन्न्योंग हासेयो" (phonetic) Input ($lineCount lines): $text Output MUST be a JSON array with EXACTLY $lineCount strings in $targetLanguage script.""" } else -> { """Translate the following $lineCount lines to $targetLanguage. IMPORTANT: - Provide natural, accurate translation - Maintain poetic flow and meaning - Keep punctuation appropriate for target language - Preserve line-by-line structure exactly - For song lyrics, prioritize singability Input ($lineCount lines): $text Output MUST be a JSON array with EXACTLY $lineCount strings.""" } } val messages = JSONArray().apply { put( JSONObject().apply { put("role", "system") put("content", systemPrompt) }, ) put( JSONObject().apply { put("role", "user") put("content", userPrompt) }, ) } val jsonBody = JSONObject().apply { if (model.isNotBlank()) { put("model", model) } put("messages", messages) put("temperature", 0.3) // Lower temperature for more consistent output put("max_tokens", lineCount * 100) // Adequate tokens for translation } val request = Request .Builder() .url(baseUrl.ifBlank { "https://openrouter.ai/api/v1/chat/completions" }) .apply { if (apiKey.isNotBlank()) { addHeader("Authorization", "Bearer ${apiKey.trim()}") } }.addHeader("Content-Type", "application/json") .addHeader("HTTP-Referer", "https://github.com/MetrolistGroup/Metrolist") .addHeader("X-Title", "Metrolist") .post(jsonBody.toString().toRequestBody(JSON)) .build() val response = client.newCall(request).execute() val responseBody = response.body?.string() if (!response.isSuccessful) { // Retry on server errors (5xx) if (response.code >= 500) { currentAttempt++ kotlinx.coroutines.delay(1000L * currentAttempt) continue } val errorMsg = try { JSONObject(responseBody ?: "").optJSONObject("error")?.optString("message") ?: "HTTP ${response.code}: ${response.message}" } catch (e: Exception) { "HTTP ${response.code}: ${response.message}" } return@withContext Result.failure(Exception("Translation failed: $errorMsg")) } if (responseBody == null) { currentAttempt++ continue } val jsonResponse = JSONObject(responseBody) val choices = jsonResponse.optJSONArray("choices") if (choices != null && choices.length() > 0) { val message = choices.getJSONObject(0).optJSONObject("message") var content = message?.optString("content")?.trim() if (!content.isNullOrBlank()) { // Enhanced JSON extraction with multiple fallback strategies var translatedLines: List? = null // Strategy 1: Try direct JSON parsing try { val jsonArray = JSONArray(content) translatedLines = (0 until jsonArray.length()).map { jsonArray.optString(it) } } catch (e: Exception) { // Strategy 2: Extract JSON from markdown code blocks content = content.replace("```json", "").replace("```", "").trim() try { val jsonArray = JSONArray(content) translatedLines = (0 until jsonArray.length()).map { jsonArray.optString(it) } } catch (e2: Exception) { // Strategy 3: Find first [ and last ] val startIdx = content.indexOf('[') val endIdx = content.lastIndexOf(']') if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) { val jsonString = content.substring(startIdx, endIdx + 1) try { val jsonArray = JSONArray(jsonString) translatedLines = (0 until jsonArray.length()).map { jsonArray.optString(it) } } catch (e3: Exception) { // Strategy 4: Manual line-by-line parsing as last resort translatedLines = content .lines() .filter { it.trim().isNotEmpty() } .map { it.trim().removeSurrounding("\"").removeSurrounding("'") } } } } } if (translatedLines != null) { // Validate line count matches if (translatedLines.size == lineCount) { return@withContext Result.success(translatedLines) } else if (translatedLines.size > lineCount) { // If we got more lines, take first N return@withContext Result.success(translatedLines.take(lineCount)) } else { // If we got fewer lines, pad with empty strings val paddedLines = translatedLines.toMutableList() while (paddedLines.size < lineCount) { paddedLines.add("") } return@withContext Result.success(paddedLines) } } } } } catch (e: Exception) { if (currentAttempt == maxRetries - 1) { return@withContext Result.failure(e) } } currentAttempt++ kotlinx.coroutines.delay(1000L * currentAttempt) } return@withContext Result.failure(Exception("Max retries exceeded")) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/api/OpenRouterStreamingService.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.api import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.serialization.json.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader import java.util.concurrent.TimeUnit object OpenRouterStreamingService { private val client = OkHttpClient .Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() private val JSON = "application/json; charset=utf-8".toMediaType() private val json = Json { ignoreUnknownKeys = true } /** * Stream translation from OpenRouter with real-time updates */ fun streamTranslation( text: String, targetLanguage: String, apiKey: String, baseUrl: String, model: String, mode: String, customSystemPrompt: String = "", ): Flow = flow { if (text.isBlank()) { emit(StreamChunk.Error("Input text is empty")) return@flow } val lines = text.lines() val lineCount = lines.size Timber.d("Starting streaming translation for $lineCount lines") try { // Use custom system prompt if provided, otherwise use the default val systemPrompt = if (customSystemPrompt.isNotBlank()) { customSystemPrompt.replace("{lineCount}", lineCount.toString()) } else { """You are a precise lyrics translation assistant. Your output must ALWAYS be a valid JSON array of strings. CRITICAL RULES: 1. Output ONLY a JSON array: ["line1", "line2", "line3"] 2. NO explanations, NO questions, NO additional text 3. Each input line maps to exactly one output line 4. Preserve empty lines as empty strings "" 5. Return EXACTLY $lineCount items in the array 6. If uncertain, provide best approximation but maintain line count""" } val userPrompt = when (mode) { "Transcribed" -> { """Transcribe/transliterate the following $lineCount lines phonetically into $targetLanguage script. CRITICAL REQUIREMENTS: - Convert the SOUND/PRONUNCIATION of the original text into $targetLanguage script - DO NOT translate the meaning - only represent how the original words SOUND - Use the native script of $targetLanguage - Preserve the original pronunciation as closely as possible - Keep punctuation and formatting Input ($lineCount lines): $text Output MUST be a JSON array with EXACTLY $lineCount strings.""" } else -> { """Translate the following $lineCount lines to $targetLanguage. IMPORTANT: - Provide natural, accurate translation - Maintain poetic flow and meaning - Keep punctuation appropriate for target language - Preserve line-by-line structure exactly Input ($lineCount lines): $text Output MUST be a JSON array with EXACTLY $lineCount strings.""" } } val messages = JSONArray().apply { put( JSONObject().apply { put("role", "system") put("content", systemPrompt) }, ) put( JSONObject().apply { put("role", "user") put("content", userPrompt) }, ) } val jsonBody = JSONObject().apply { if (model.isNotBlank()) { put("model", model) } put("messages", messages) put("stream", true) put("temperature", 0.3) put("max_tokens", lineCount * 100) } val request = Request .Builder() .url(baseUrl.ifBlank { "https://openrouter.ai/api/v1/chat/completions" }) .apply { if (apiKey.isNotBlank()) { addHeader("Authorization", "Bearer ${apiKey.trim()}") } }.addHeader("Content-Type", "application/json") .addHeader("HTTP-Referer", "https://github.com/MetrolistGroup/Metrolist") .addHeader("X-Title", "Metrolist") .post(jsonBody.toString().toRequestBody(JSON)) .build() client.newCall(request).execute().use { response -> Timber.d("Got streaming response: ${response.code}") if (!response.isSuccessful) { val errorMsg = try { JSONObject(response.body?.string() ?: "") .optJSONObject("error") ?.optString("message") ?: "HTTP ${response.code}: ${response.message}" } catch (e: Exception) { "HTTP ${response.code}: ${response.message}" } emit(StreamChunk.Error("Translation failed: $errorMsg")) return@flow } val reader = BufferedReader(InputStreamReader(response.body?.byteStream())) var line: String? val contentBuilder = StringBuilder() var chunkCount = 0 while (reader.readLine().also { line = it } != null) { line?.let { currentLine -> if (currentLine.startsWith("data: ")) { val data = currentLine.substring(6) if (data == "[DONE]") { Timber.d("Streaming complete, received $chunkCount chunks") // Processing complete, parse the full content val fullContent = contentBuilder.toString() Timber.d("Full content length: ${fullContent.length}") val result = parseTranslationContent(fullContent, lineCount) result .onSuccess { translatedLines -> Timber.d("Successfully parsed ${translatedLines.size} lines") emit(StreamChunk.Complete(translatedLines)) }.onFailure { error -> Timber.e(error, "Failed to parse translation") emit(StreamChunk.Error(error.message ?: "Parsing failed")) } return@flow } try { val jsonObject = json.parseToJsonElement(data).jsonObject val choices = jsonObject["choices"]?.jsonArray val delta = choices ?.get(0) ?.jsonObject ?.get("delta") ?.jsonObject val content = delta?.get("content")?.jsonPrimitive?.content content?.let { chunk -> contentBuilder.append(chunk) chunkCount++ emit(StreamChunk.Content(chunk)) } } catch (e: Exception) { // Ignore malformed JSON chunks Timber.v("Ignored malformed chunk: ${e.message}") } } } } // If we got here without seeing [DONE], try to parse what we have if (contentBuilder.isNotEmpty()) { Timber.w("Stream ended without [DONE] marker, attempting to parse content") val fullContent = contentBuilder.toString() val result = parseTranslationContent(fullContent, lineCount) result .onSuccess { translatedLines -> emit(StreamChunk.Complete(translatedLines)) }.onFailure { error -> emit(StreamChunk.Error(error.message ?: "Parsing failed")) } } } } catch (e: Exception) { Timber.e(e, "Streaming error") emit(StreamChunk.Error(e.message ?: "Unknown error")) } }.flowOn(Dispatchers.IO) private fun parseTranslationContent( content: String, expectedLineCount: Int, ): Result> { var translatedLines: List? = null // Strategy 1: Try direct JSON parsing try { val jsonArray = JSONArray(content.trim()) translatedLines = (0 until jsonArray.length()).map { jsonArray.optString(it) } } catch (e: Exception) { // Strategy 2: Extract JSON from markdown code blocks var cleanedContent = content.replace("```json", "").replace("```", "").trim() try { val jsonArray = JSONArray(cleanedContent) translatedLines = (0 until jsonArray.length()).map { jsonArray.optString(it) } } catch (e2: Exception) { // Strategy 3: Find first [ and last ] val startIdx = cleanedContent.indexOf('[') val endIdx = cleanedContent.lastIndexOf(']') if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) { val jsonString = cleanedContent.substring(startIdx, endIdx + 1) try { val jsonArray = JSONArray(jsonString) translatedLines = (0 until jsonArray.length()).map { jsonArray.optString(it) } } catch (e3: Exception) { // Strategy 4: Manual line-by-line parsing as last resort translatedLines = cleanedContent .lines() .filter { it.trim().isNotEmpty() } .map { it.trim().removeSurrounding("\"").removeSurrounding("'") } } } } } if (translatedLines == null) { return Result.failure(Exception("Failed to parse translation")) } // Adjust line count return when { translatedLines.size == expectedLineCount -> { Result.success(translatedLines) } translatedLines.size > expectedLineCount -> { Result.success(translatedLines.take(expectedLineCount)) } else -> { val paddedLines = translatedLines.toMutableList() while (paddedLines.size < expectedLineCount) { paddedLines.add("") } Result.success(paddedLines) } } } sealed class StreamChunk { data class Content( val text: String, ) : StreamChunk() data class Complete( val translatedLines: List, ) : StreamChunk() data class Error( val message: String, ) : StreamChunk() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/constants/Dimensions.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 SlimNavBarHeight = 64.dp val MiniPlayerHeight = 64.dp val MinMiniPlayerHeight = 16.dp val MiniPlayerBottomSpacing = 8.dp // Space between MiniPlayer and NavigationBar 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 = 3.dp val PlayerHorizontalPadding = 32.dp val NavigationBarAnimationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow ) val BottomSheetAnimationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow ) val BottomSheetSoftAnimationSpec = spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/constants/HistorySource.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.constants enum class HistorySource { LOCAL, REMOTE } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/constants/LibraryFilter.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.constants enum class LibraryFilter { SONGS, ARTISTS, ALBUMS, PLAYLISTS, PODCASTS, LIBRARY, } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/constants/MediaSessionConstants.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.constants import android.os.Bundle import androidx.media3.session.SessionCommand object MediaSessionConstants { const val ACTION_TOGGLE_LIBRARY = "TOGGLE_LIBRARY" const val ACTION_TOGGLE_START_RADIO = "TOGGLE_START_RADIO" const val ACTION_TOGGLE_LIKE = "TOGGLE_LIKE" const val ACTION_TOGGLE_SHUFFLE = "TOGGLE_SHUFFLE" const val ACTION_TOGGLE_REPEAT_MODE = "TOGGLE_REPEAT_MODE" const val ACTION_ADD_TO_TARGET_PLAYLIST = "ADD_TO_TARGET_PLAYLIST" val CommandToggleLibrary = SessionCommand(ACTION_TOGGLE_LIBRARY, Bundle.EMPTY) val CommandToggleLike = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY) val CommandToggleStartRadio = SessionCommand(ACTION_TOGGLE_START_RADIO, Bundle.EMPTY) val CommandToggleShuffle = SessionCommand(ACTION_TOGGLE_SHUFFLE, Bundle.EMPTY) val CommandToggleRepeatMode = SessionCommand(ACTION_TOGGLE_REPEAT_MODE, Bundle.EMPTY) val CommandAddToTargetPlaylist = SessionCommand(ACTION_ADD_TO_TARGET_PLAYLIST, Bundle.EMPTY) const val TARGET_PLAYLIST_AUTO = "auto" } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 EnableDynamicIconKey = booleanPreferencesKey("enableDynamicIcon") val EnableHighRefreshRateKey = booleanPreferencesKey("enableHighRefreshRate") val DynamicThemeKey = booleanPreferencesKey("dynamicTheme") val SelectedThemeColorKey = intPreferencesKey("selectedThemeColor") val DarkModeKey = stringPreferencesKey("darkMode") val PureBlackKey = booleanPreferencesKey("pureBlack") val PureBlackMiniPlayerKey = booleanPreferencesKey("pureBlackMiniPlayer") val MiniPlayerOutlineKey = booleanPreferencesKey("miniPlayerOutline") val DensityScaleKey = floatPreferencesKey("density_scale_factor") val CustomDensityScaleKey = floatPreferencesKey("custom_density_scale_value") enum class DensityScale( val value: Float, val label: String, ) { NATIVE(1.0f, "Native (100%)"), SLIGHTLY_COMPACT(0.85f, "Slightly Compact (85%)"), COMPACT(0.75f, "Compact (75%)"), VERY_COMPACT(0.65f, "Very Compact (65%)"), ULTRA_COMPACT(0.55f, "Ultra Compact (55%)"), ; companion object { fun fromValue(value: Float): DensityScale = entries.find { it.value == value } ?: NATIVE } } val DefaultOpenTabKey = stringPreferencesKey("defaultOpenTab") val SlimNavBarKey = booleanPreferencesKey("slimNavBar") val GridItemsSizeKey = stringPreferencesKey("gridItemSize") val SliderStyleKey = stringPreferencesKey("sliderStyle") val SquigglySliderKey = booleanPreferencesKey("squigglySlider") val SwipeToSongKey = booleanPreferencesKey("SwipeToSong") val SwipeToRemoveSongKey = booleanPreferencesKey("SwipeToRemoveSong") val UseNewPlayerDesignKey = booleanPreferencesKey("useNewPlayerDesign") val UseNewMiniPlayerDesignKey = booleanPreferencesKey("useNewMiniPlayerDesign") val HidePlayerThumbnailKey = booleanPreferencesKey("hidePlayerThumbnail") val CropAlbumArtKey = booleanPreferencesKey("cropAlbumArt") val SeekExtraSeconds = booleanPreferencesKey("seekExtraSeconds") val PauseOnMute = booleanPreferencesKey("pauseOnMute") val ResumeOnBluetoothConnectKey = booleanPreferencesKey("resumeOnBluetoothConnect") val KeepScreenOn = booleanPreferencesKey("keepScreenOn") val AlarmEnabledKey = booleanPreferencesKey("alarmEnabled") val AlarmHourKey = intPreferencesKey("alarmHour") val AlarmMinuteKey = intPreferencesKey("alarmMinute") val AlarmPlaylistIdKey = stringPreferencesKey("alarmPlaylistId") val AlarmRandomSongKey = booleanPreferencesKey("alarmRandomSong") val AlarmNextTriggerAtKey = longPreferencesKey("alarmNextTriggerAt") val AlarmEntriesKey = stringPreferencesKey("alarmEntries") val DeveloperModeKey = booleanPreferencesKey("developerMode") enum class SliderStyle { DEFAULT, WAVY, SLIM, } const val SYSTEM_DEFAULT = "SYSTEM_DEFAULT" val AppLanguageKey = stringPreferencesKey("appLanguage") val ContentLanguageKey = stringPreferencesKey("contentLanguage") val ContentCountryKey = stringPreferencesKey("contentCountry") val EnableKugouKey = booleanPreferencesKey("enableKugou") val EnableLrcLibKey = booleanPreferencesKey("enableLrclib") val EnableBetterLyricsKey = booleanPreferencesKey("enableBetterLyrics") val EnableSimpMusicKey = booleanPreferencesKey("enableSimpMusic") val EnableLyricsPlus = booleanPreferencesKey("enableLyricsPlus") val HideExplicitKey = booleanPreferencesKey("hideExplicit") val HideVideoSongsKey = booleanPreferencesKey("hideVideoSongs") val HideYoutubeShortsKey = booleanPreferencesKey("hideYoutubeShorts") val ShowArtistDescriptionKey = booleanPreferencesKey("showArtistDescription") val ShowArtistSubscriberCountKey = booleanPreferencesKey("showArtistSubscriberCount") val ShowMonthlyListenersKey = booleanPreferencesKey("showMonthlyListeners") val ProxyEnabledKey = booleanPreferencesKey("proxyEnabled") val ProxyUrlKey = stringPreferencesKey("proxyUrl") val ProxyTypeKey = stringPreferencesKey("proxyType") val ProxyUsernameKey = stringPreferencesKey("proxyUsername") val ProxyPasswordKey = stringPreferencesKey("proxyPassword") val YtmSyncKey = booleanPreferencesKey("ytmSync") val SelectedYtmPlaylistsKey = stringPreferencesKey("selectedYtmPlaylists") val CheckForUpdatesKey = booleanPreferencesKey("checkForUpdates") val UpdateNotificationsEnabledKey = booleanPreferencesKey("updateNotifications") val LastUpdateCheckTimeKey = longPreferencesKey("lastUpdateCheckTime") val AudioQualityKey = stringPreferencesKey("audioQuality") enum class AudioQuality { AUTO, HIGH, LOW, } val AudioOffload = booleanPreferencesKey("enableOffload") val PersistentQueueKey = booleanPreferencesKey("persistentQueue") val PersistentShuffleAcrossQueuesKey = booleanPreferencesKey("persistentShuffleAcrossQueues") val RememberShuffleAndRepeatKey = booleanPreferencesKey("rememberShuffleAndRepeat") val ShuffleModeKey = booleanPreferencesKey("shuffleMode") val SkipSilenceKey = booleanPreferencesKey("skipSilence") val SkipSilenceInstantKey = booleanPreferencesKey("skipSilenceInstant") val AudioNormalizationKey = booleanPreferencesKey("audioNormalization") val AutoLoadMoreKey = booleanPreferencesKey("autoLoadMore") val DisableLoadMoreWhenRepeatAllKey = booleanPreferencesKey("disableLoadMoreWhenRepeatAll") val AutoDownloadOnLikeKey = booleanPreferencesKey("autoDownloadOnLike") val SimilarContent = booleanPreferencesKey("similarContent") val AutoSkipNextOnErrorKey = booleanPreferencesKey("autoSkipNextOnError") val StopMusicOnTaskClearKey = booleanPreferencesKey("stopMusicOnTaskClear") val ShufflePlaylistFirstKey = booleanPreferencesKey("shufflePlaylistFirst") val PreventDuplicateTracksInQueueKey = booleanPreferencesKey("preventDuplicateTracksInQueue") val CrossfadeEnabledKey = booleanPreferencesKey("crossfadeEnabled") val CrossfadeDurationKey = floatPreferencesKey("crossfadeDurationFloat") val CrossfadeGaplessKey = booleanPreferencesKey("crossfadeGapless") val MaxImageCacheSizeKey = intPreferencesKey("maxImageCacheSize") val MaxSongCacheSizeKey = intPreferencesKey("maxSongCacheSize") val EnableSongCacheKey = booleanPreferencesKey("enableSongCache") 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 DiscordUseDetailsKey = booleanPreferencesKey("discordUseDetails") val DiscordAvatarKey = stringPreferencesKey("discordAvatar") val DiscordStatusKey = stringPreferencesKey("discordStatus") val DiscordButton1TextKey = stringPreferencesKey("discordButton1Text") val DiscordButton1VisibleKey = booleanPreferencesKey("discordButton1Visible") val DiscordButton2TextKey = stringPreferencesKey("discordButton2Text") val DiscordButton2VisibleKey = booleanPreferencesKey("discordButton2Visible") val DiscordActivityTypeKey = stringPreferencesKey("discordActivityType") val DiscordActivityNameKey = stringPreferencesKey("discordActivityName") val DiscordAdvancedModeKey = booleanPreferencesKey("discordAdvancedMode") // Google Cast val EnableGoogleCastKey = booleanPreferencesKey("enableGoogleCast") // Listen Together val ListenTogetherServerUrlKey = stringPreferencesKey("listenTogetherServerUrl") val ListenTogetherUsernameKey = stringPreferencesKey("listenTogetherUsername") val EnableListenTogetherKey = booleanPreferencesKey("enableListenTogether") val ListenTogetherAutoApprovalKey = booleanPreferencesKey("listenTogetherAutoApproval") val ListenTogetherAutoApproveSuggestionsKey = booleanPreferencesKey("listenTogetherAutoApproveSuggestions") val ListenTogetherSyncVolumeKey = booleanPreferencesKey("listenTogetherSyncVolume") val ListenTogetherBlockedUsersKey = stringPreferencesKey("listenTogetherBlockedUsers") val ListenTogetherInTopBarKey = booleanPreferencesKey("listenTogetherInTopBar") // Session persistence for reconnection val ListenTogetherSessionTokenKey = stringPreferencesKey("listenTogetherSessionToken") val ListenTogetherRoomCodeKey = stringPreferencesKey("listenTogetherRoomCode") val ListenTogetherUserIdKey = stringPreferencesKey("listenTogetherUserId") val ListenTogetherIsHostKey = booleanPreferencesKey("listenTogetherIsHost") val ListenTogetherSessionTimestampKey = longPreferencesKey("listenTogetherSessionTimestamp") val LastFMSessionKey = stringPreferencesKey("lastfmSession") val LastFMUsernameKey = stringPreferencesKey("lastfmUsername") val EnableLastFMScrobblingKey = booleanPreferencesKey("lastfmScrobblingEnable") val LastFMUseNowPlaying = booleanPreferencesKey("lastfmUseNowPlaying") val LastFMUseSendLikes = booleanPreferencesKey("lastfmUseSendLikes") val ScrobbleDelayPercentKey = floatPreferencesKey("scrobbleDelayPercent") val ScrobbleMinSongDurationKey = intPreferencesKey("scrobbleMinSongDuration") val ScrobbleDelaySecondsKey = intPreferencesKey("scrobbleDelaySeconds") 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 AddToPlaylistSortTypeKey = stringPreferencesKey("addToPlaylistSortType") val AddToPlaylistSortDescendingKey = booleanPreferencesKey("addToPlaylistSortDescending") 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 PodcastFilterKey = stringPreferencesKey("podcastFilter") val LastLikeSongSyncKey = longPreferencesKey("last_like_song_sync") val LastLibSongSyncKey = longPreferencesKey("last_library_song_sync") val LastAlbumSyncKey = longPreferencesKey("last_album_sync") val LastArtistSyncKey = longPreferencesKey("last_artist_sync") val LastPlaylistSyncKey = longPreferencesKey("last_playlist_sync") val LastFullSyncKey = longPreferencesKey("last_full_sync") val LastWeeklyMostPlaylistSyncKey = longPreferencesKey("last_weekly_most_playlist_sync") val LastMonthlyMostPlaylistSyncKey = longPreferencesKey("last_monthly_most_playlist_sync") // Sync cooldown in seconds (30 minutes) const val SYNC_COOLDOWN = 30 * 60L 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 LyricsProviderOrderKey = stringPreferencesKey("lyricsProviderOrder") val QueueEditLockKey = booleanPreferencesKey("queueEditLock") val ShowWrappedCardKey = booleanPreferencesKey("show_wrapped_card") val WrappedSeenKey = booleanPreferencesKey("wrapped_seen") val LastSeenVersionKey = stringPreferencesKey("lastSeenVersion") val RandomizeHomeOrderKey = booleanPreferencesKey("randomizeHomeOrder") val ShowLikedPlaylistKey = booleanPreferencesKey("show_liked_playlist") val ShowDownloadedPlaylistKey = booleanPreferencesKey("show_downloaded_playlist") val ShowTopPlaylistKey = booleanPreferencesKey("show_top_playlist") val ShowCachedPlaylistKey = booleanPreferencesKey("show_cached_playlist") val ShowUploadedPlaylistKey = booleanPreferencesKey("show_uploaded_playlist") enum class LibraryViewType { LIST, GRID, ; fun toggle() = when (this) { LIST -> GRID GRID -> LIST } } enum class SongFilter { LIBRARY, LIKED, DOWNLOADED, UPLOADED, } enum class ArtistFilter { LIBRARY, LIKED, } enum class AlbumFilter { LIBRARY, LIKED, UPLOADED, } enum class PodcastFilter { EPISODES, CHANNELS, DOWNLOADED, } 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 { BIG, SMALL, } 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, BETTER_LYRICS, SIMPMUSIC, } enum class PlayerButtonsStyle { DEFAULT, PRIMARY, TERTIARY, } enum class PlayerBackgroundStyle { DEFAULT, GRADIENT, BLUR, } val TopSize = stringPreferencesKey("topSize") val HistoryDuration = floatPreferencesKey("historyDuration") val PlayerButtonsStyleKey = stringPreferencesKey("player_buttons_style") val PlayerBackgroundStyleKey = stringPreferencesKey("playerBackgroundStyle") val ShowLyricsKey = booleanPreferencesKey("showLyrics") val LyricsTextPositionKey = stringPreferencesKey("lyricsTextPosition") val LyricsClickKey = booleanPreferencesKey("lyricsClick") val LyricsScrollKey = booleanPreferencesKey("lyricsScrollKey") val LyricsRomanizeAsMainKey = booleanPreferencesKey("lyricsRomanizeAsMain") val LyricsRomanizeCyrillicByLineKey = booleanPreferencesKey("lyricsRomanizeCyrillicByLine") val OpenRouterApiKey = stringPreferencesKey("openRouterApiKey") val AiProviderKey = stringPreferencesKey("aiProvider") val OpenRouterBaseUrlKey = stringPreferencesKey("openRouterBaseUrl") val OpenRouterModelKey = stringPreferencesKey("openRouterModel") val TranslateModeKey = stringPreferencesKey("translateMode") val TranslateLanguageKey = stringPreferencesKey("translateLanguage") val DeeplApiKey = stringPreferencesKey("deeplApiKey") val DeeplFormalityKey = stringPreferencesKey("deeplFormality") val AiSystemPromptKey = stringPreferencesKey("aiSystemPrompt") const val DEFAULT_AI_SYSTEM_PROMPT = """You are a precise lyrics translation assistant. Your output must ALWAYS be a valid JSON array of strings. CRITICAL RULES: 1. Output ONLY a JSON array: ["line1", "line2", "line3"] 2. NO explanations, NO questions, NO additional text 3. Each input line maps to exactly one output line 4. Preserve empty lines as empty strings "" 5. Return EXACTLY {lineCount} items in the array 6. If uncertain, provide best approximation but maintain line count""" val LyricsGlowEffectKey = booleanPreferencesKey("lyricsGlowEffect") val LyricsRomanizeList = stringPreferencesKey("lyricsRomanizeList") val LyricsAnimationStyleKey = stringPreferencesKey("lyricsAnimationStyle") enum class LyricsAnimationStyle { NONE, FADE, GLOW, SLIDE, KARAOKE, APPLE, } val LyricsTextSizeKey = floatPreferencesKey("lyricsTextSize") val LyricsLineSpacingKey = floatPreferencesKey("lyricsLineSpacing") val PlayerVolumeKey = floatPreferencesKey("playerVolume") val SleepTimerDefaultKey = floatPreferencesKey("sleepTimerDefault") val SleepTimerStopAfterCurrentSongKey = booleanPreferencesKey("sleepTimerStopAfterCurrentSong") val SleepTimerFadeOutKey = booleanPreferencesKey("sleepTimerFadeOut") val RepeatModeKey = intPreferencesKey("repeatMode") val SearchSourceKey = stringPreferencesKey("searchSource") val SwipeThumbnailKey = booleanPreferencesKey("swipeThumbnail") val SwipeSensitivityKey = floatPreferencesKey("swipeSensitivity") val SleepTimerEnabledKey = booleanPreferencesKey("sleepTimerEnabled") val SleepTimerRepeatKey = stringPreferencesKey("sleepTimerRepeat") val SleepTimerStartTimeKey = stringPreferencesKey("sleepTimerStartTime") val SleepTimerEndTimeKey = stringPreferencesKey("sleepTimerEndTime") val SleepTimerCustomDaysKey = stringPreferencesKey("sleepTimerCustomDays") val SleepTimerDayTimesKey = stringPreferencesKey("sleepTimerDayTimes") enum class SearchSource { LOCAL, ONLINE, ; fun toggle() = when (this) { LOCAL -> ONLINE ONLINE -> LOCAL } } val VisitorDataKey = stringPreferencesKey("visitorData") val DataSyncIdKey = stringPreferencesKey("dataSyncId") val AndroidAutoYouTubePlaylistsKey = booleanPreferencesKey("androidAutoYoutubePlaylists") val AndroidAutoSectionsOrderKey = stringPreferencesKey("androidAutoSectionsOrder") val AndroidAutoTargetPlaylistKey = stringPreferencesKey("androidAutoTargetPlaylist") 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", ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/constants/StatPeriod.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.constants import com.metrolist.music.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() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/Converters.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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() } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/DatabaseDao.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.RewriteQueriesToDropUnusedColumns import androidx.room.RoomWarnings import androidx.room.Transaction import androidx.room.Update import androidx.room.Upsert import androidx.sqlite.db.SupportSQLiteQuery import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.pages.AlbumPage import com.metrolist.innertube.pages.ArtistPage import com.metrolist.music.constants.AlbumSortType import com.metrolist.music.constants.ArtistSongSortType import com.metrolist.music.constants.ArtistSortType import com.metrolist.music.constants.PlaylistSortType import com.metrolist.music.constants.SongSortType import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.AlbumArtistMap import com.metrolist.music.db.entities.AlbumEntity import com.metrolist.music.db.entities.AlbumWithSongs import com.metrolist.music.db.entities.Artist import com.metrolist.music.db.entities.ArtistEntity import com.metrolist.music.db.entities.Event import com.metrolist.music.db.entities.EventWithSong import com.metrolist.music.db.entities.FormatEntity import com.metrolist.music.db.entities.LyricsEntity import com.metrolist.music.db.entities.PlayCountEntity import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.PlaylistSong import com.metrolist.music.db.entities.PlaylistSongMap import com.metrolist.music.db.entities.PodcastEntity import com.metrolist.music.db.entities.RecognitionHistory import com.metrolist.music.db.entities.RelatedSongMap import com.metrolist.music.db.entities.SearchHistory import com.metrolist.music.db.entities.SetVideoIdEntity import com.metrolist.music.db.entities.Song import com.metrolist.music.db.entities.SongAlbumMap import com.metrolist.music.db.entities.SongArtistMap import com.metrolist.music.db.entities.SongEntity import com.metrolist.music.db.entities.SongWithStats import com.metrolist.music.extensions.reversed import com.metrolist.music.extensions.toSQLiteQuery import com.metrolist.music.models.MediaMetadata import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.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.orderedArtists.joinToString("") { it.name } }, ).groupBy { it.album?.title } .flatMap { (_, songsByAlbum) -> songsByAlbum.sortedBy { album -> album.orderedArtists.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.orderedArtists.joinToString("") { it.name } }, ).groupBy { it.album?.title } .flatMap { (_, songsByAlbum) -> songsByAlbum.sortedBy { album -> album.orderedArtists.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 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, fromTimeStamp: Long? = null, toTimeStamp: Long? = null, limit: Int = -1 ): Flow> { val songsFlow = 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 -> { if (fromTimeStamp != null && toTimeStamp != null) { mostPlayedSongsByArtist(artistId, fromTimeStamp, toTimeStamp) } else { artistSongsByPlayTimeAsc(artistId) } } } return songsFlow.map { songs -> val limitedSongs = if (limit > 0) songs.take(limit) else songs limitedSongs.reversed(descending) } } @Transaction @RewriteQueriesToDropUnusedColumns @Query( """ SELECT s.* FROM song s JOIN ( SELECT e.songId, SUM(e.playTime) as totalPlayTime FROM event e JOIN song_artist_map sam ON e.songId = sam.songId WHERE sam.artistId = :artistId AND e.timestamp >= :fromTimeStamp AND e.timestamp <= :toTimeStamp GROUP BY e.songId ) AS play_times ON s.id = play_times.songId ORDER BY play_times.totalPlayTime DESC """ ) fun mostPlayedSongsByArtist(artistId: String, fromTimeStamp: Long, toTimeStamp: Long): 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 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 s.id, s.title, s.thumbnailUrl, s.isVideo, (SELECT name FROM artist WHERE id = sam.artistId) as artistName, (SELECT COUNT(1) FROM event WHERE songId = s.id AND timestamp > :fromTimeStamp AND timestamp <= :toTimeStamp) AS songCountListened, (SELECT SUM(event.playTime) FROM event WHERE songId = s.id AND timestamp > :fromTimeStamp AND timestamp <= :toTimeStamp) AS timeListened FROM song s LEFT JOIN song_artist_map sam ON s.id = sam.songId JOIN (SELECT songId FROM event WHERE timestamp > :fromTimeStamp AND timestamp <= :toTimeStamp GROUP BY songId ORDER BY SUM(playTime) DESC LIMIT :limit) AS top_songs ON s.id = top_songs.songId GROUP BY s.id ORDER BY timeListened DESC LIMIT :limit OFFSET :offset """, ) fun mostPlayedSongsStats( fromTimeStamp: Long, limit: Int = 6, offset: Int = 0, toTimeStamp: Long? = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli(), ): Flow> // Time Transfer @Query("UPDATE event SET songId = :toSongId WHERE songId = :fromSongId") suspend fun transferEvents(fromSongId: String, toSongId: String): Int // 1) Load source rows @Query("SELECT * FROM playCount WHERE song = :fromSongId") suspend fun getPlayCountsForSong(fromSongId: String): List // 2) Try to add into existing target row @Query( """ UPDATE playCount SET count = count + :delta WHERE song = :toSongId AND year = :year AND month = :month """, ) suspend fun addToPlayCountRow(toSongId: String, year: Int, month: Int, delta: Int): Int // 3) Insert new target row if none existed @androidx.room.Insert(onConflict = androidx.room.OnConflictStrategy.IGNORE) suspend fun insertPlayCountRow(row: PlayCountEntity): Long @Query("DELETE FROM playCount WHERE song = :fromSongId") suspend fun deletePlayCountsForSong(fromSongId: String): Int @Transaction suspend fun transferSongStats(fromSongId: String, toSongId: String) { require(fromSongId != toSongId) { "fromSongId and toSongId must differ" } val movedPlayTime = getTotalPlayTimeForSong(fromSongId) ?: 0L // 1) move events (source loses them) transferEvents(fromSongId, toSongId) // 2) merge playCount rows into target and remove source rows val rows = getPlayCountsForSong(fromSongId) for (r in rows) { val updated = addToPlayCountRow(toSongId, r.year, r.month, r.count) if (updated == 0) { // no target row existed -> create it insertPlayCountRow( PlayCountEntity( song = toSongId, year = r.year, month = r.month, count = r.count, ), ) } } deletePlayCountsForSong(fromSongId) if (movedPlayTime != 0L) { incrementTotalPlayTime(toSongId, movedPlayTime) incrementTotalPlayTime(fromSongId, -movedPlayTime) } } // Time Transfer @Transaction @RewriteQueriesToDropUnusedColumns @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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(playTime) FROM event WHERE timestamp >= :fromTimeStamp AND timestamp <= :toTimeStamp") fun getTotalPlayTimeInRange(fromTimeStamp: Long, toTimeStamp: Long): Flow @Query("SELECT SUM(playTime) FROM event WHERE songId = :songId") fun getTotalPlayTimeForSong(songId: String): Long? @Query("SELECT COUNT(DISTINCT songId) FROM event WHERE timestamp >= :fromTimeStamp AND timestamp <= :toTimeStamp") fun getUniqueSongCountInRange(fromTimeStamp: Long, toTimeStamp: Long): Flow @Query( """ SELECT COUNT(DISTINCT artistId) FROM event JOIN song_artist_map ON event.songId = song_artist_map.songId WHERE timestamp >= :fromTimeStamp AND timestamp <= :toTimeStamp """ ) fun getUniqueArtistCountInRange(fromTimeStamp: Long, toTimeStamp: Long): Flow @Query( """ SELECT COUNT(DISTINCT albumId) FROM event JOIN song ON event.songId = song.id WHERE timestamp >= :fromTimeStamp AND timestamp <= :toTimeStamp """ ) fun getUniqueAlbumCountInRange(fromTimeStamp: Long, toTimeStamp: Long): Flow @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query( """ SELECT album.*, count(song.dateDownload) downloadCount FROM album_artist_map JOIN album ON album_artist_map.albumId = album.id JOIN song ON album_artist_map.albumId = song.albumId WHERE artistId = :artistId GROUP BY album.id LIMIT :previewSize """ ) fun artistAlbumsPreview(artistId: String, previewSize: Int = 6): 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") suspend fun getSongById(songId: String): Song? @Transaction @Query("SELECT * FROM song WHERE id = :songId LIMIT 1") fun getSongByIdBlocking(songId: String): Song? @Transaction @Query("SELECT * FROM song WHERE id IN (:songIds)") suspend fun getSongsByIds(songIds: List): List @Transaction @Query("SELECT * FROM song_artist_map WHERE songId = :songId") fun songArtistMap(songId: String): List @Transaction @Query("SELECT * FROM song") fun allSongs(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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> @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 @Transaction @Query("SELECT * FROM lyrics WHERE id = :id") fun lyrics(id: String?): Flow @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 songCount") fun artistsBySongCountAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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> fun artists(sortType: ArtistSortType, descending: Boolean) = when (sortType) { ArtistSortType.CREATE_DATE -> artistsByCreateDateAsc() ArtistSortType.NAME -> artistsByNameAsc() ArtistSortType.SONG_COUNT -> artistsBySongCountAsc() ArtistSortType.PLAY_TIME -> artistsByPlayTimeAsc() }.map { artists -> artists .filter { it.artist.isYouTubeArtist || it.artist.isLocal } // TODO: add ui to filter by local or remote or something idk .reversed(descending) } fun artistsBookmarked(sortType: ArtistSortType, descending: Boolean) = when (sortType) { ArtistSortType.CREATE_DATE -> artistsBookmarkedByCreateDateAsc() ArtistSortType.NAME -> artistsBookmarkedByNameAsc() ArtistSortType.SONG_COUNT -> artistsBookmarkedBySongCountAsc() ArtistSortType.PLAY_TIME -> artistsBookmarkedByPlayTimeAsc() }.map { artists -> artists .filter { it.artist.isYouTubeArtist || it.artist.isLocal } // TODO: add ui to filter by local or remote or something idk .reversed(descending) } @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY rowId") fun albumsLikedByCreateDateAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY title") fun albumsLikedByNameAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY year") fun albumsLikedByYearAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY songCount") fun albumsLikedBySongCountAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY duration") fun albumsLikedByLengthAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE isUploaded = 1 ORDER BY rowId") fun albumsUploadedByCreateDateAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY title") fun albumsUploadedByNameAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY year") fun albumsUploadedByYearAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY songCount") fun albumsUploadedBySongCountAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY duration") fun albumsUploadedByLengthAsc(): Flow> @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 albumsUploadedByPlayTimeAsc(): 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) } fun albumsUploaded( sortType: AlbumSortType, descending: Boolean, ) = when (sortType) { AlbumSortType.CREATE_DATE -> albumsUploadedByCreateDateAsc() AlbumSortType.NAME -> albumsUploadedByNameAsc().map { albums -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY albums.sortedWith(compareBy(collator) { it.album.title }) } AlbumSortType.ARTIST -> albumsUploadedByCreateDateAsc().map { albums -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY albums.sortedWith(compareBy(collator) { album -> album.artists.joinToString("") { it.name } }) } AlbumSortType.YEAR -> albumsUploadedByYearAsc() AlbumSortType.SONG_COUNT -> albumsUploadedBySongCountAsc() AlbumSortType.LENGTH -> albumsUploadedByLengthAsc() AlbumSortType.PLAY_TIME -> albumsUploadedByPlayTimeAsc() }.map { it.reversed(descending) } @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 isEditable AND bookmarkedAt IS NOT NULL ORDER BY name") fun editablePlaylistsByNameAsc(): 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 @Query("UPDATE playlist SET lastUpdateTime = :now WHERE id = :playlistId") fun updatePlaylistLastUpdated( playlistId: String, now: LocalDateTime = LocalDateTime.now(), ) @Transaction fun addSongToPlaylist(playlist: Playlist, songIds: List) { var position = playlist.songCount songIds.forEach { id -> insert( PlaylistSongMap( songId = id, playlistId = playlist.id, position = position++ ) ) } updatePlaylistLastUpdated(playlist.id) } fun downloadedSongs( sortType: SongSortType, descending: Boolean ): Flow> = when (sortType) { SongSortType.CREATE_DATE -> downloadedSongsByCreateDateAsc() SongSortType.NAME -> downloadedSongsByNameAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs.sortedWith(compareBy(collator) { it.song.title }) } SongSortType.ARTIST -> downloadedSongsByNameAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs.sortedWith(compareBy(collator) { song -> song.orderedArtists.joinToString("") { it.name } }) } SongSortType.PLAY_TIME -> downloadedSongsByPlayTimeAsc() }.map { it.reversed(descending) } @Transaction @Query("SELECT * FROM song WHERE (isDownloaded = 1 OR isCached = 1) AND (isEpisode = 0 OR isEpisode IS NULL) ORDER BY dateDownload") fun downloadedSongsByCreateDateAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE (isDownloaded = 1 OR isCached = 1) AND (isEpisode = 0 OR isEpisode IS NULL) ORDER BY title") fun downloadedSongsByNameAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE (isDownloaded = 1 OR isCached = 1) AND (isEpisode = 0 OR isEpisode IS NULL) ORDER BY totalPlayTime") fun downloadedSongsByPlayTimeAsc(): Flow> @Query("UPDATE song SET isDownloaded = :downloaded, dateDownload = :date WHERE id = :songId") fun updateDownloadedInfo(songId: String, downloaded: Boolean, date: LocalDateTime?) @Query("UPDATE song SET isCached = :cached WHERE id = :songId") fun updateCachedInfo(songId: String, cached: Boolean) @Query("UPDATE song SET isCached = 1 WHERE id IN (:songIds)") fun updateCachedInfoMany(songIds: List) @Query("UPDATE song SET playbackPosition = :position WHERE id = :songId") fun updatePlaybackPosition(songId: String, position: Long?) @Query("SELECT playbackPosition FROM song WHERE id = :songId") fun getPlaybackPosition(songId: String): Long? @Query("SELECT playbackPosition FROM song WHERE id = :songId") fun playbackPositionFlow(songId: String): Flow @Transaction @Query("SELECT * FROM song WHERE isUploaded = 1 ORDER BY dateDownload") fun uploadedSongsByCreateDateAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isUploaded = 1 ORDER BY title") fun uploadedSongsByNameAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isUploaded = 1 ORDER BY totalPlayTime") fun uploadedSongsByPlayTimeAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isUploaded = 1 ORDER BY rowId") fun uploadedSongsByRowIdAsc(): Flow> fun uploadedSongs( sortType: SongSortType, descending: Boolean, ) = when (sortType) { SongSortType.CREATE_DATE -> uploadedSongsByCreateDateAsc() SongSortType.NAME -> uploadedSongsByNameAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs.sortedWith(compareBy(collator) { it.song.title }) } SongSortType.ARTIST -> uploadedSongsByRowIdAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs .sortedWith( compareBy(collator) { song -> song.orderedArtists.joinToString("") { it.name } }, ).groupBy { it.album?.title } .flatMap { (_, songsByAlbum) -> songsByAlbum.sortedBy { album -> album.orderedArtists.joinToString( "", ) { it.name } } } } SongSortType.PLAY_TIME -> uploadedSongsByPlayTimeAsc() }.map { it.reversed(descending) } @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 ORDER BY inLibrary") fun podcastEpisodesByCreateDateAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 ORDER BY title") fun podcastEpisodesByNameAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 ORDER BY totalPlayTime") fun podcastEpisodesByPlayTimeAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 ORDER BY rowId") fun podcastEpisodesByRowIdAsc(): Flow> fun podcastEpisodes( sortType: SongSortType, descending: Boolean, ) = when (sortType) { SongSortType.CREATE_DATE -> podcastEpisodesByCreateDateAsc() SongSortType.NAME -> podcastEpisodesByNameAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs.sortedWith(compareBy(collator) { it.song.title }) } SongSortType.ARTIST -> podcastEpisodesByRowIdAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs .sortedWith( compareBy(collator) { song -> song.orderedArtists.joinToString("") { it.name } }, ).groupBy { it.album?.title } .flatMap { (_, songsByAlbum) -> songsByAlbum.sortedBy { album -> album.orderedArtists.joinToString( "", ) { it.name } } } } SongSortType.PLAY_TIME -> podcastEpisodesByPlayTimeAsc() }.map { it.reversed(descending) } @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 AND (isDownloaded = 1 OR isCached = 1) ORDER BY dateDownload") fun downloadedPodcastEpisodesByCreateDateAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 AND (isDownloaded = 1 OR isCached = 1) ORDER BY title") fun downloadedPodcastEpisodesByNameAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 AND (isDownloaded = 1 OR isCached = 1) ORDER BY totalPlayTime") fun downloadedPodcastEpisodesByPlayTimeAsc(): Flow> // Saved episodes (in library but not necessarily downloaded) @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 AND inLibrary IS NOT NULL ORDER BY inLibrary DESC") fun savedPodcastEpisodesByCreateDateAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 AND inLibrary IS NOT NULL ORDER BY title") fun savedPodcastEpisodesByNameAsc(): Flow> @Transaction @Query("SELECT * FROM song WHERE isEpisode = 1 AND inLibrary IS NOT NULL ORDER BY totalPlayTime") fun savedPodcastEpisodesByPlayTimeAsc(): Flow> fun savedPodcastEpisodes( sortType: SongSortType, descending: Boolean, ) = when (sortType) { SongSortType.CREATE_DATE -> savedPodcastEpisodesByCreateDateAsc() SongSortType.NAME -> savedPodcastEpisodesByNameAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs.sortedWith(compareBy(collator) { it.song.title }) } SongSortType.ARTIST -> savedPodcastEpisodesByNameAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs.sortedWith(compareBy(collator) { song -> song.orderedArtists.joinToString("") { it.name } }) } SongSortType.PLAY_TIME -> savedPodcastEpisodesByPlayTimeAsc() }.map { it.reversed(descending) } fun downloadedPodcastEpisodes( sortType: SongSortType, descending: Boolean, ) = when (sortType) { SongSortType.CREATE_DATE -> downloadedPodcastEpisodesByCreateDateAsc() SongSortType.NAME -> downloadedPodcastEpisodesByNameAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs.sortedWith(compareBy(collator) { it.song.title }) } SongSortType.ARTIST -> downloadedPodcastEpisodesByNameAsc().map { songs -> val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY songs.sortedWith(compareBy(collator) { song -> song.orderedArtists.joinToString("") { it.name } }) } SongSortType.PLAY_TIME -> downloadedPodcastEpisodesByPlayTimeAsc() }.map { it.reversed(descending) } @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 @Query("SELECT COUNT(*) FROM event") fun eventCount(): 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() // Recognition History @Transaction @Query("SELECT * FROM recognition_history ORDER BY recognizedAt DESC") fun recognitionHistory(): Flow> @Transaction @Query("SELECT * FROM recognition_history WHERE id = :id") fun recognitionHistoryById(id: Long): Flow @Transaction @Query("SELECT * FROM recognition_history WHERE title LIKE '%' || :query || '%' OR artist LIKE '%' || :query || '%' ORDER BY recognizedAt DESC") fun searchRecognitionHistory(query: String): Flow> @Transaction @Query("DELETE FROM recognition_history") fun clearRecognitionHistory() @Transaction @Query("DELETE FROM recognition_history WHERE id = :id") fun deleteRecognitionHistoryById(id: Long) @Transaction @Query("UPDATE recognition_history SET liked = :liked WHERE id = :id") fun updateRecognitionHistoryLiked(id: Long, liked: Boolean) @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(recognitionHistory: RecognitionHistory): Long @Delete fun delete(recognitionHistory: RecognitionHistory) @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("UPDATE song SET libraryAddToken = :libraryAddToken, libraryRemoveToken = :libraryRemoveToken WHERE id = :songId") fun addLibraryTokens( songId: String, libraryAddToken: String?, libraryRemoveToken: String?, ) @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? @Query("SELECT * FROM artist WHERE id = :id LIMIT 1") fun getArtistById(id: 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 @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(setVideoIdEntity: SetVideoIdEntity) @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, channelId = artist.id, ) ) 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 }, explicit = albumPage.album.explicit || albumPage.songs.any { it.explicit }, ), ) == -1L ) { return } albumPage.songs .map(SongItem::toMediaMetadata) .onEach(::insert) .onEach { val existingSong = getSongByIdBlocking(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, libraryAddToken = mediaMetadata.libraryAddToken, libraryRemoveToken = mediaMetadata.libraryRemoveToken ), ) 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, channelId = artist.id, ), ) 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) @Transaction 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 }, explicit = albumPage.album.explicit || albumPage.songs.any { it.explicit }, ), ) if (artists?.size != albumPage.album.artists?.size) { artists?.forEach(::delete) } albumPage.songs .map(SongItem::toMediaMetadata) .onEach(::insert) .onEach { val existingSong = getSongByIdBlocking(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, thumbnailUrl = playlistItem.thumbnail, 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) @Upsert fun upsert(song: SongEntity) @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()) } // Podcast methods @Query("SELECT * FROM podcast WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC") fun subscribedPodcasts(): Flow> @Query("SELECT * FROM podcast WHERE id = :id") fun podcast(id: String): Flow @Query("SELECT EXISTS(SELECT 1 FROM podcast WHERE channelId = :channelId AND bookmarkedAt IS NOT NULL)") fun hasSubscribedPodcastByChannelId(channelId: String): Flow @Transaction @SuppressWarnings(RoomWarnings.QUERY_MISMATCH) @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 artist.bookmarkedAt IS NOT NULL AND artist.isPodcastChannel = 1 ORDER BY artist.name COLLATE NOCASE ASC """) fun bookmarkedPodcastChannels(): Flow> @Query("SELECT * FROM podcast WHERE channelId = :channelId") fun podcastsByChannelId(channelId: String): Flow> @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(podcast: PodcastEntity): Long @Update fun update(podcast: PodcastEntity) @Upsert fun upsert(podcast: PodcastEntity) @Delete fun delete(podcast: PodcastEntity) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.music.db.daos.SpeedDialDao import com.metrolist.music.db.entities.AlbumArtistMap import com.metrolist.music.db.entities.AlbumEntity import com.metrolist.music.db.entities.ArtistEntity import com.metrolist.music.db.entities.Event import com.metrolist.music.db.entities.FormatEntity import com.metrolist.music.db.entities.LyricsEntity import com.metrolist.music.db.entities.PlayCountEntity import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.PlaylistSongMap import com.metrolist.music.db.entities.PlaylistSongMapPreview import com.metrolist.music.db.entities.PodcastEntity import com.metrolist.music.db.entities.RecognitionHistory import com.metrolist.music.db.entities.RelatedSongMap import com.metrolist.music.db.entities.SearchHistory import com.metrolist.music.db.entities.SetVideoIdEntity import com.metrolist.music.db.entities.SongAlbumMap import com.metrolist.music.db.entities.SongArtistMap import com.metrolist.music.db.entities.SongEntity import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.db.entities.SortedSongAlbumMap import com.metrolist.music.db.entities.SortedSongArtistMap import com.metrolist.music.extensions.toSQLiteQuery import timber.log.Timber 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 speedDialDao: SpeedDialDao get() = delegate.speedDialDao 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) } } } suspend fun withTransaction(block: suspend MusicDatabase.() -> Unit) = with(delegate) { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { runInTransaction { kotlinx.coroutines.runBlocking { 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, RecognitionHistory::class, SpeedDialItem::class, PodcastEntity::class ], views = [ SortedSongArtistMap::class, SortedSongAlbumMap::class, PlaylistSongMapPreview::class, ], version = 36, 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, spec = Migration18To19::class), AutoMigration(from = 19, to = 20, spec = Migration19To20::class), AutoMigration(from = 20, to = 21, spec = Migration20To21::class), AutoMigration(from = 21, to = 22, spec = Migration21To22::class), AutoMigration(from = 22, to = 23, spec = Migration22To23::class), AutoMigration(from = 23, to = 24, spec = Migration23To24::class), AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), AutoMigration(from = 26, to = 27), AutoMigration(from = 27, to = 28), AutoMigration(from = 28, to = 29), AutoMigration(from = 29, to = 30, spec = Migration29To30::class), AutoMigration(from = 30, to = 31), AutoMigration(from = 31, to = 32), AutoMigration(from = 32, to = 33), AutoMigration(from = 33, to = 34), AutoMigration(from = 34, to = 35), AutoMigration(from = 35, to = 36, spec = Migration35To36::class), ], ) @TypeConverters(Converters::class) abstract class InternalDatabase : RoomDatabase() { abstract val dao: DatabaseDao abstract val speedDialDao: SpeedDialDao 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, MIGRATION_21_24, MIGRATION_22_24, MIGRATION_24_25, ) .fallbackToDestructiveMigration(dropAllTables = true) .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) .setTransactionExecutor(java.util.concurrent.Executors.newFixedThreadPool(4)) .setQueryExecutor(java.util.concurrent.Executors.newFixedThreadPool(4)) .addCallback(object : RoomDatabase.Callback() { override fun onOpen(db: SupportSQLiteDatabase) { super.onOpen(db) try { db.query("PRAGMA busy_timeout = 60000").close() db.query("PRAGMA cache_size = -16000").close() db.query("PRAGMA wal_autocheckpoint = 1000").close() db.query("PRAGMA synchronous = NORMAL").close() } catch (e: Exception) { Timber.tag("MusicDatabase").e(e, "Failed to set PRAGMA settings") } } }) .build(), ) } } // ===== Migrations ===== val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { data class OldSongEntity( val id: String, val title: String, val duration: Int = -1, val thumbnailUrl: String? = null, val albumId: String? = null, val albumName: String? = null, val liked: Boolean = false, val totalPlayTime: Long = 0, val downloadState: Int = 0, val createDate: LocalDateTime = LocalDateTime.now(), val modifyDate: LocalDateTime = LocalDateTime.now(), ) val converters = Converters() val artistMap = mutableMapOf() val artists = mutableListOf() db.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() db.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() db.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), ), ) } } 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() db.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, ), ) } } db.execSQL("DROP TABLE IF EXISTS song") db.execSQL("DROP TABLE IF EXISTS artist") db.execSQL("DROP TABLE IF EXISTS playlist") db.execSQL("DROP TABLE IF EXISTS playlist_song") db.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`))", ) db.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`))", ) db.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`))", ) db.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`))", ) db.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 )", ) db.execSQL("CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `song_artist_map` (`songId`)") db.execSQL("CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `song_artist_map` (`artistId`)") db.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 )", ) db.execSQL("CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `song_album_map` (`songId`)") db.execSQL("CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `song_album_map` (`albumId`)") db.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 )", ) db.execSQL("CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `album_artist_map` (`albumId`)") db.execSQL("CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `album_artist_map` (`artistId`)") db.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 )", ) db.execSQL("CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `playlist_song_map` (`playlistId`)") db.execSQL("CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `playlist_song_map` (`songId`)") db.execSQL("CREATE TABLE IF NOT EXISTS `download` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))") db.execSQL( "CREATE TABLE IF NOT EXISTS `search_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", ) db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `search_history` (`query`)") db.execSQL("CREATE VIEW `sorted_song_artist_map` AS SELECT * FROM song_artist_map ORDER BY position") db.execSQL( "CREATE VIEW `playlist_song_map_preview` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position", ) artists.forEach { artist -> db.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 -> db.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 -> db.insert( "song_artist_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( "songId" to songArtistMap.songId, "artistId" to songArtistMap.artistId, "position" to songArtistMap.position, ), ) } playlists.forEach { playlist -> db.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 -> db.insert( "playlist_song_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( "playlistId" to playlistSongMap.playlistId, "songId" to playlistSongMap.songId, "position" to playlistSongMap.position, ), ) } } } val MIGRATION_21_24 = object : Migration(21, 24) { override fun migrate(db: SupportSQLiteDatabase) { // Combine all changes from 21→22→23→24 // From 21→22: Add columns try { db.execSQL("ALTER TABLE song ADD COLUMN libraryAddToken TEXT DEFAULT ''") } catch (e: Exception) { Timber.tag("Migration").w("Column libraryAddToken may already exist") } try { db.execSQL("ALTER TABLE song ADD COLUMN libraryRemoveToken TEXT DEFAULT ''") } catch (e: Exception) { Timber.tag("Migration").w("Column libraryRemoveToken may already exist") } try { db.execSQL("ALTER TABLE song ADD COLUMN romanizeLyrics INTEGER NOT NULL DEFAULT 1") } catch (e: Exception) { Timber.tag("Migration").w("Column romanizeLyrics may already exist") } try { db.execSQL("ALTER TABLE song ADD COLUMN isDownloaded INTEGER NOT NULL DEFAULT 0") } catch (e: Exception) { Timber.tag("Migration").w("Column isDownloaded may already exist") } // From 23→24: Add isUploaded var hasIsUploaded = false db.query("PRAGMA table_info('song')").use { cursor -> val nameIndex = cursor.getColumnIndex("name") while (cursor.moveToNext()) { val colName = if (nameIndex >= 0) cursor.getString(nameIndex) else null if (colName == "isUploaded") { hasIsUploaded = true break } } } if (!hasIsUploaded) { db.execSQL("ALTER TABLE `song` ADD COLUMN `isUploaded` INTEGER NOT NULL DEFAULT 0") } } } val MIGRATION_22_24 = object : Migration(22, 24) { override fun migrate(db: SupportSQLiteDatabase) { // From 23→24: Add isUploaded var hasIsUploaded = false db.query("PRAGMA table_info('song')").use { cursor -> val nameIndex = cursor.getColumnIndex("name") while (cursor.moveToNext()) { val colName = if (nameIndex >= 0) cursor.getString(nameIndex) else null if (colName == "isUploaded") { hasIsUploaded = true break } } } if (!hasIsUploaded) { db.execSQL("ALTER TABLE `song` ADD COLUMN `isUploaded` INTEGER NOT NULL DEFAULT 0") } } } // ===== AutoMigration Specs ===== @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") } } class Migration18To19 : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { db.execSQL("UPDATE song SET explicit = 0 WHERE explicit IS NULL") } } class Migration19To20 : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { db.execSQL("UPDATE song SET explicit = 0 WHERE explicit IS NULL") } } @DeleteColumn.Entries( DeleteColumn( tableName = "song", columnName = "artistName" ) ) class Migration20To21 : AutoMigrationSpec class Migration21To22 : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { try { db.execSQL("ALTER TABLE song ADD COLUMN libraryAddToken TEXT DEFAULT ''") } catch (e: Exception) { Timber.tag("Migration21To22").w(e, "Column may already exist") } try { db.execSQL("ALTER TABLE song ADD COLUMN libraryRemoveToken TEXT DEFAULT ''") } catch (e: Exception) { Timber.tag("Migration21To22").w(e, "Column may already exist") } try { db.execSQL("ALTER TABLE song ADD COLUMN romanizeLyrics INTEGER NOT NULL DEFAULT 1") } catch (e: Exception) { Timber.tag("Migration21To22").w(e, "Column may already exist") } try { db.execSQL("ALTER TABLE song ADD COLUMN isDownloaded INTEGER NOT NULL DEFAULT 0") } catch (e: Exception) { Timber.tag("Migration21To22").w(e, "Column may already exist") } } } class Migration22To23 : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { // No changes needed for 22→23 } } class Migration23To24: AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { var hasIsUploaded = false db.query("PRAGMA table_info('song')").use { cursor -> val nameIndex = cursor.getColumnIndex("name") while (cursor.moveToNext()) { val colName = if (nameIndex >= 0) cursor.getString(nameIndex) else null if (colName == "isUploaded") { hasIsUploaded = true break } } } if (!hasIsUploaded) { db.execSQL("ALTER TABLE `song` ADD COLUMN `isUploaded` INTEGER NOT NULL DEFAULT 0") } } } val MIGRATION_24_25 = object : Migration(24, 25) { override fun migrate(db: SupportSQLiteDatabase) { // Add perceptualLoudnessDb column to format table for improved audio normalization var columnExists = false db.query("PRAGMA table_info(format)").use { cursor -> val nameIndex = cursor.getColumnIndex("name") while (cursor.moveToNext()) { if (cursor.getString(nameIndex) == "perceptualLoudnessDb") { columnExists = true break } } } if (!columnExists) { // Add the column allowing NULL values (since existing rows won't have this data) db.execSQL("ALTER TABLE format ADD COLUMN perceptualLoudnessDb REAL DEFAULT NULL") } } } class Migration29To30 : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { // Ensure isVideo column exists (safeguard) var hasIsVideo = false db.query("PRAGMA table_info('song')").use { cursor -> val nameIndex = cursor.getColumnIndex("name") while (cursor.moveToNext()) { val colName = if (nameIndex >= 0) cursor.getString(nameIndex) else null if (colName == "isVideo") { hasIsVideo = true break } } } if (!hasIsVideo) { db.execSQL("ALTER TABLE song ADD COLUMN isVideo INTEGER NOT NULL DEFAULT 0") } // Ensure provider column exists in lyrics table var hasProvider = false db.query("PRAGMA table_info('lyrics')").use { cursor -> val nameIndex = cursor.getColumnIndex("name") while (cursor.moveToNext()) { val colName = if (nameIndex >= 0) cursor.getString(nameIndex) else null if (colName == "provider") { hasProvider = true break } } } if (!hasProvider) { db.execSQL("ALTER TABLE lyrics ADD COLUMN provider TEXT NOT NULL DEFAULT 'Unknown'") } } } class Migration35To36 : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { var hasIsCached = false db.query("PRAGMA table_info('song')").use { cursor -> val nameIndex = cursor.getColumnIndex("name") while (cursor.moveToNext()) { val colName = if (nameIndex >= 0) cursor.getString(nameIndex) else null if (colName == "isCached") { hasIsCached = true break } } } if (!hasIsCached) { db.execSQL("ALTER TABLE song ADD COLUMN isCached INTEGER NOT NULL DEFAULT 0") } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/daos/SpeedDialDao.kt ================================================ package com.metrolist.music.db.daos import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.metrolist.music.db.entities.SpeedDialItem import kotlinx.coroutines.flow.Flow @Dao interface SpeedDialDao { @Query("SELECT * FROM speed_dial_item ORDER BY createDate ASC") fun getAll(): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(item: SpeedDialItem) @Query("DELETE FROM speed_dial_item WHERE id = :id") suspend fun delete(id: String) @Query("SELECT EXISTS(SELECT * FROM speed_dial_item WHERE id = :id)") fun isPinned(id: String): Flow } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/Album.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 = emptyList(), val songCountListened: Int? = 0, val timeListened: Long? = 0 ) : LocalItem() { override val id: String get() = album.id override val title: String get() = album.title override val thumbnailUrl: String? get() = album.thumbnailUrl } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/AlbumArtistMap.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/AlbumEntity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import com.metrolist.innertube.YouTube import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers 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, @ColumnInfo(defaultValue = "0") val explicit: Boolean = false, val lastUpdateTime: LocalDateTime = LocalDateTime.now(), val bookmarkedAt: LocalDateTime? = null, val likedDate: LocalDateTime? = null, val inLibrary: LocalDateTime? = null, @ColumnInfo(name = "isLocal", defaultValue = false.toString()) val isLocal: Boolean = false, @ColumnInfo(name = "isUploaded", defaultValue = false.toString()) val isUploaded: Boolean = false ) { fun localToggleLike() = copy( bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now() ) fun toggleUploaded() = copy( isUploaded = !isUploaded ) fun toggleLibrary() = copy( inLibrary = if (inLibrary != null) null else LocalDateTime.now() ) fun toggleLike() = localToggleLike().also { CoroutineScope(Dispatchers.IO).launch { if (playlistId != null) YouTube.likePlaylist(playlistId, bookmarkedAt == null) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/AlbumWithSongs.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/Artist.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities import androidx.compose.runtime.Immutable import androidx.room.Embedded @Immutable data class Artist( @Embedded val artist: ArtistEntity, val songCount: Int, 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 } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/ArtistEntity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import com.metrolist.innertube.YouTube import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers 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, val lastUpdateTime: LocalDateTime = LocalDateTime.now(), val bookmarkedAt: LocalDateTime? = null, @ColumnInfo(name = "isLocal", defaultValue = false.toString()) val isLocal: Boolean = false, @ColumnInfo(name = "isPodcastChannel", defaultValue = false.toString()) val isPodcastChannel: Boolean = false ) { 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") fun localToggleLike() = copy( bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now(), ) fun toggleLike() = localToggleLike().also { CoroutineScope(Dispatchers.IO).launch { val targetChannelId = channelId ?: YouTube.getChannelId(id) if (targetChannelId.isNotEmpty()) { YouTube.subscribeChannel(targetChannelId, bookmarkedAt == null) } } } companion object { fun generateArtistId() = "LA" + RandomStringUtils.insecure().next(8, true, false) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/Event.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/EventWithSong.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/FormatEntity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 perceptualLoudnessDb: Double? = null, @Deprecated("playbackTrackingUrl should be retrieved from a fresh player request") val playbackUrl: String? ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/LocalItem.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities sealed class LocalItem { abstract val id: String abstract val title: String abstract val thumbnailUrl: String? } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/LyricsEntity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "lyrics") data class LyricsEntity( @PrimaryKey val id: String, val lyrics: String, @ColumnInfo(defaultValue = "Unknown") val provider: String = "Unknown", @ColumnInfo(defaultValue = "") val translatedLyrics: String = "", @ColumnInfo(defaultValue = "") val translationLanguage: String = "", @ColumnInfo(defaultValue = "") val translationMode: String = "", ) { companion object { const val LYRICS_NOT_FOUND = "LYRICS_NOT_FOUND" } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/PlayCountEntity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/Playlist.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 songThumbnails: List, ) : LocalItem() { override val id: String get() = playlist.id override val title: String get() = playlist.name override val thumbnailUrl: String? get() = null val thumbnails: List get() { return if (playlist.thumbnailUrl != null) listOf(playlist.thumbnailUrl) else songThumbnails.filterNotNull() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/PlaylistEntity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import com.metrolist.innertube.YouTube import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers 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 thumbnailUrl: String? = null, val shuffleEndpointParams: String? = null, val radioEndpointParams: String? = null, @ColumnInfo(name = "isLocal", defaultValue = false.toString()) val isLocal: Boolean = false, @ColumnInfo(name = "isAutoSync", defaultValue = false.toString()) val isAutoSync: Boolean = false ) { companion object { const val LIKED_PLAYLIST_ID = "LP_LIKED" const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED" const val WEEKLY_MOST_PLAYLIST_ID = "LP_WEEKLY_MOST" const val MONTHLY_MOST_PLAYLIST_ID = "LP_MONTHLY_MOST" fun generatePlaylistId() = "LP" + RandomStringUtils.insecure().next(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) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/PlaylistSong.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/PlaylistSongMap.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/PlaylistSongMapPreview.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/PodcastEntity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey import java.time.LocalDateTime /** * Podcast library entries with YTM "Save to Library" sync support. * * Podcasts are saved using the likePlaylist API (like/like endpoint with playlistId). * The podcast ID format is "MPSP", e.g., "MPSPPLxxx..." where the * playlistId is extracted by removing the "MPSP" prefix. * * Note: channelId, libraryAddToken, libraryRemoveToken are legacy fields kept * for backwards compatibility. The correct API is likePlaylist(). */ @Immutable @Entity(tableName = "podcast") data class PodcastEntity( @PrimaryKey val id: String, val title: String, val author: String? = null, val thumbnailUrl: String? = null, val channelId: String? = null, val bookmarkedAt: LocalDateTime? = null, val lastUpdateTime: LocalDateTime = LocalDateTime.now(), val libraryAddToken: String? = null, val libraryRemoveToken: String? = null, ) { fun toggleBookmark() = copy( bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now(), lastUpdateTime = LocalDateTime.now(), ) val inLibrary: Boolean get() = bookmarkedAt != null } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/RecognitionHistory.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import java.time.LocalDateTime @Entity( tableName = "recognition_history", indices = [ Index( value = ["trackId"], unique = false, ), ], ) data class RecognitionHistory( @PrimaryKey(autoGenerate = true) val id: Long = 0, val trackId: String, val title: String, val artist: String, val album: String? = null, val coverArtUrl: String? = null, val coverArtHqUrl: String? = null, val genre: String? = null, val releaseDate: String? = null, val label: String? = null, val shazamUrl: String? = null, val appleMusicUrl: String? = null, val spotifyUrl: String? = null, val isrc: String? = null, val youtubeVideoId: String? = null, val recognizedAt: LocalDateTime = LocalDateTime.now(), val liked: Boolean = false ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/RelatedSongMap.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/SearchHistory.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/SetVideoIdEntity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/Song.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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( parentColumn = "id", entityColumn = "songId", ) val artistMaps: List = emptyList(), @Relation( entity = AlbumEntity::class, entityColumn = "id", parentColumn = "id", associateBy = Junction( value = SongAlbumMap::class, parentColumn = "songId", entityColumn = "albumId", ), ) val album: AlbumEntity? = null, @Relation( parentColumn = "id", entityColumn = "id" ) val format: FormatEntity? = null, ) : LocalItem() { override val id: String get() = song.id override val title: String get() = song.title override val thumbnailUrl: String? get() = song.thumbnailUrl val romanizeLyrics: Boolean get() = song.romanizeLyrics val orderedArtists: List get() { if (artistMaps.isEmpty()) return artists val artistsById = artists.associateBy { it.id } val sorted = artistMaps .sortedBy { it.position } .mapNotNull { map -> artistsById[map.artistId] } return sorted.ifEmpty { artists } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/SongAlbumMap.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/SongArtistMap.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/SongEntity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import com.metrolist.innertube.YouTube import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers 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, @ColumnInfo(defaultValue = "0") val explicit: Boolean = false, 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, @ColumnInfo(name = "isLocal", defaultValue = false.toString()) val isLocal: Boolean = false, val libraryAddToken: String? = null, val libraryRemoveToken: String? = null, @ColumnInfo(defaultValue = "0") val lyricsOffset: Int = 0, @ColumnInfo(defaultValue = true.toString()) val romanizeLyrics: Boolean = true, @ColumnInfo(defaultValue = "0") val isDownloaded: Boolean = false, @ColumnInfo(name = "isUploaded", defaultValue = false.toString()) val isUploaded: Boolean = false, @ColumnInfo(name = "isVideo", defaultValue = false.toString()) val isVideo: Boolean = false, @ColumnInfo(name = "isEpisode", defaultValue = false.toString()) val isEpisode: Boolean = false, @ColumnInfo(name = "playbackPosition", defaultValue = "NULL") val playbackPosition: Long? = null, @ColumnInfo(name = "uploadEntityId", defaultValue = "NULL") val uploadEntityId: String? = null, @ColumnInfo(name = "isCached", defaultValue = "0") val isCached: Boolean = false ) { 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) } } fun toggleLibrary(syncToYouTube: Boolean = true) = copy( liked = if (inLibrary == null) liked else false, inLibrary = if (inLibrary == null) LocalDateTime.now() else null, likedDate = if (inLibrary == null) likedDate else null ).also { if (syncToYouTube) { CoroutineScope(Dispatchers.IO).launch { // Use the new reliable method that fetches fresh tokens val addToLibrary = inLibrary == null YouTube.toggleSongLibrary(id, addToLibrary) } } } fun toggleUploaded() = copy( isUploaded = !isUploaded ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/SongWithStats.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.db.entities import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation @Immutable data class SongWithStats( val id: String, val title: String, @Relation( entity = ArtistEntity::class, parentColumn = "id", // Song's primary key column entityColumn = "id", // Artist's primary key column associateBy = Junction( value = SortedSongArtistMap::class, // Junction table for the many-to-many relationship parentColumn = "songId", // Foreign key to the Song table entityColumn = "artistId" // Foreign key to the Artist table ) ) val artists: List, val thumbnailUrl: String, val artistName: String?, val songCountListened: Int, val timeListened: Long?, val isVideo: Boolean = false, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/SortedSongAlbumMap.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/SortedSongArtistMap.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/db/entities/SpeedDialItem.kt ================================================ package com.metrolist.music.db.entities import androidx.room.Entity import androidx.room.PrimaryKey import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.Artist import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.YTItem @Entity(tableName = "speed_dial_item") data class SpeedDialItem( @PrimaryKey val id: String, val secondaryId: String? = null, val title: String, val subtitle: String? = null, val thumbnailUrl: String? = null, val type: String, // "SONG", "ALBUM", "ARTIST", "PLAYLIST", "LOCAL_PLAYLIST" val explicit: Boolean = false, val createDate: Long = System.currentTimeMillis() ) { fun toYTItem(): YTItem { return when (type) { "SONG" -> SongItem( id = id, title = title, artists = subtitle?.split(", ")?.map { Artist(name = it, id = null) } ?: emptyList(), thumbnail = thumbnailUrl ?: "", explicit = explicit ) "ALBUM" -> AlbumItem( browseId = id, playlistId = secondaryId ?: "", title = title, artists = subtitle?.split(", ")?.map { Artist(name = it, id = null) }, thumbnail = thumbnailUrl ?: "", explicit = explicit ) "ARTIST" -> ArtistItem( id = id, title = title, thumbnail = thumbnailUrl, shuffleEndpoint = null, radioEndpoint = null ) "PLAYLIST", "LOCAL_PLAYLIST" -> PlaylistItem( id = id, title = title, author = subtitle?.let { Artist(name = it, id = null) }, songCountText = null, thumbnail = thumbnailUrl, playEndpoint = null, shuffleEndpoint = null, radioEndpoint = null ) else -> throw IllegalArgumentException("Unknown type: $type") } } companion object { fun fromYTItem(item: YTItem): SpeedDialItem { return when (item) { is SongItem -> SpeedDialItem( id = item.id, title = item.title, subtitle = item.artists.joinToString(", ") { it.name }, thumbnailUrl = item.thumbnail, type = "SONG", explicit = item.explicit ) is AlbumItem -> SpeedDialItem( id = item.browseId, secondaryId = item.playlistId, title = item.title, subtitle = item.artists?.joinToString(", ") { it.name }, thumbnailUrl = item.thumbnail, type = "ALBUM", explicit = item.explicit ) is ArtistItem -> SpeedDialItem( id = item.id, title = item.title, thumbnailUrl = item.thumbnail, type = "ARTIST" ) is PlaylistItem -> SpeedDialItem( id = item.id, title = item.title, subtitle = item.author?.name, thumbnailUrl = item.thumbnail, type = "PLAYLIST" ) is PodcastItem -> SpeedDialItem( id = item.id, title = item.title, subtitle = item.author?.name, thumbnailUrl = item.thumbnail, type = "PLAYLIST" ) is EpisodeItem -> SpeedDialItem( id = item.id, title = item.title, subtitle = item.author?.name, thumbnailUrl = item.thumbnail, type = "SONG", explicit = item.explicit ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/di/AppModule.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 androidx.room.Room import com.metrolist.music.constants.MaxSongCacheSizeKey import com.metrolist.music.db.InternalDatabase import com.metrolist.music.db.MusicDatabase import com.metrolist.music.listentogether.ListenTogetherClient import com.metrolist.music.listentogether.ListenTogetherManager import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object AppModule { @Provides @Singleton @ApplicationScope fun provideApplicationScope(): CoroutineScope { return CoroutineScope(SupervisorJob() + Dispatchers.Default) } @Singleton @Provides fun provideDao( database: InternalDatabase, ) = database.dao @Singleton @Provides fun provideInternalDatabase( @ApplicationContext context: Context, ): InternalDatabase = Room .databaseBuilder(context, InternalDatabase::class.java, InternalDatabase.DB_NAME) .build() @Singleton @Provides fun provideDatabase( internalDatabase: InternalDatabase, ): MusicDatabase = MusicDatabase(internalDatabase) @Singleton @Provides fun provideDatabaseProvider( @ApplicationContext context: Context, ): DatabaseProvider = StandaloneDatabaseProvider(context) @Singleton @Provides @PlayerCache fun providePlayerCache( @ApplicationContext context: Context, databaseProvider: DatabaseProvider, musicDatabase: MusicDatabase, ): SimpleCache { val cacheSize = context.dataStore[MaxSongCacheSizeKey] ?: 1024 return SimpleCache( context.filesDir.resolve("exoplayer"), com.metrolist.music.playback.MetrolistCacheEvictor( when (cacheSize) { -1 -> NoOpCacheEvictor() else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) }, musicDatabase ), databaseProvider, ) } @Singleton @Provides @DownloadCache fun provideDownloadCache( @ApplicationContext context: Context, databaseProvider: DatabaseProvider, ): SimpleCache { return SimpleCache( context.filesDir.resolve("download"), NoOpCacheEvictor(), databaseProvider ) } @Singleton @Provides fun provideListenTogetherClient( @ApplicationContext context: Context, ): ListenTogetherClient = ListenTogetherClient(context) @Singleton @Provides fun provideListenTogetherManager( @ApplicationContext context: Context, client: ListenTogetherClient, ): ListenTogetherManager = ListenTogetherManager(client, context) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/di/LyricsHelperEntryPoint.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.di import com.metrolist.music.lyrics.LyricsHelper import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @EntryPoint @InstallIn(SingletonComponent::class) interface LyricsHelperEntryPoint { fun lyricsHelper(): LyricsHelper } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/di/NetworkModule.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.di import android.content.Context import com.metrolist.music.utils.NetworkConnectivityObserver 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 NetworkModule { @Provides @Singleton fun provideNetworkConnectivityObserver(@ApplicationContext context: Context): NetworkConnectivityObserver { return NetworkConnectivityObserver(context) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/di/Qualifiers.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.di import javax.inject.Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class PlayerCache @Qualifier @Retention(AnnotationRetention.BINARY) annotation class DownloadCache @Qualifier @Retention(AnnotationRetention.BINARY) annotation class ApplicationScope ================================================ FILE: app/src/main/kotlin/com/metrolist/music/di/WrappedModule.kt ================================================ package com.metrolist.music.di import android.content.Context import com.metrolist.music.db.DatabaseDao import com.metrolist.music.ui.screens.wrapped.WrappedAudioService import com.metrolist.music.ui.screens.wrapped.WrappedManager 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 WrappedModule { @Provides @Singleton fun provideWrappedManager( databaseDao: DatabaseDao, @ApplicationContext context: Context, ): WrappedManager = WrappedManager(databaseDao, context) @Provides @Singleton fun provideWrappedAudioService(@ApplicationContext context: Context): WrappedAudioService = WrappedAudioService(context) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/eq/EqualizerService.kt ================================================ package com.metrolist.music.eq import android.annotation.SuppressLint import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import com.metrolist.music.eq.audio.CustomEqualizerAudioProcessor import com.metrolist.music.eq.data.ParametricEQ import com.metrolist.music.eq.data.SavedEQProfile import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton /** * Service for managing custom EQ using ExoPlayer's AudioProcessor * Supports 10+ band Parametric EQ format (APO) */ @Singleton class EqualizerService @Inject constructor() { @SuppressLint("UnsafeOptInUsageError") private val audioProcessors = mutableListOf() private var pendingProfile: SavedEQProfile? = null private var shouldDisable: Boolean = false companion object { private const val TAG = "EqualizerService" } /** * Add an audio processor instance * This should be called when ExoPlayer is initialized */ @OptIn(UnstableApi::class) fun addAudioProcessor(processor: CustomEqualizerAudioProcessor) { audioProcessors.add(processor) Timber.tag(TAG).d("Audio processor added. Total: ${audioProcessors.size}") // Apply pending profile if one was set before processor was available if (shouldDisable) { processor.disable() // Don't clear shouldDisable here, as we might add more processors } else if (pendingProfile != null) { val profile = pendingProfile!! applyProfileToProcessor(processor, profile) // Don't clear pendingProfile here } } /** * Remove an audio processor instance */ fun removeAudioProcessor(processor: CustomEqualizerAudioProcessor) { audioProcessors.remove(processor) } /** * Apply an EQ profile * If audio processor is not set, stores as pending profile */ @OptIn(UnstableApi::class) fun applyProfile(profile: SavedEQProfile): Result { if (audioProcessors.isEmpty()) { Timber.tag(TAG) .w("No audio processors set yet. Storing profile as pending: ${profile.name}") pendingProfile = profile shouldDisable = false return Result.success(Unit) } pendingProfile = profile // Keep it for future processors shouldDisable = false var success = true var lastError: Exception? = null audioProcessors.forEach { processor -> try { applyProfileToProcessor(processor, profile) } catch (e: Exception) { success = false lastError = e } } return if (success) Result.success(Unit) else Result.failure(lastError ?: Exception("Unknown error")) } private fun applyProfileToProcessor(processor: CustomEqualizerAudioProcessor, profile: SavedEQProfile) { val parametricEQ = ParametricEQ( preamp = profile.preamp, bands = profile.bands ) processor.applyProfile(parametricEQ) } /** * Disable the equalizer (flat response) * If audio processor is not set, stores pending disable request */ @OptIn(UnstableApi::class) fun disable() { if (audioProcessors.isEmpty()) { Timber.tag(TAG).w("No audio processors set yet. Storing disable as pending") shouldDisable = true pendingProfile = null return } shouldDisable = true // Keep state pendingProfile = null audioProcessors.forEach { processor -> try { processor.disable() } catch (e: Exception) { Timber.tag(TAG).e("Failed to disable equalizer: ${e.message}") } } Timber.tag(TAG).d("Equalizer disabled on all processors") } /** * Check if audio processor is set */ fun isInitialized(): Boolean { return audioProcessors.isNotEmpty() } /** * Check if equalizer is enabled */ @OptIn(UnstableApi::class) fun isEnabled(): Boolean { return audioProcessors.any { it.isEnabled() } } /** * Get information about the current EQ capabilities */ fun getEqualizerInfo(): EqualizerInfo { return EqualizerInfo( supportsUnlimitedBands = true, maxBands = Int.MAX_VALUE, description = "Custom ExoPlayer AudioProcessor with biquad filters" ) } /** * Release resources (not needed for AudioProcessor, but kept for API compatibility) */ fun release() { // AudioProcessor is managed by ExoPlayer, we just clear our reference audioProcessors.clear() Timber.tag(TAG).d("Audio processor references cleared") } } /** * Information about equalizer capabilities */ data class EqualizerInfo( val supportsUnlimitedBands: Boolean, val maxBands: Int, val description: String ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/eq/audio/BiquadFilter.kt ================================================ package com.metrolist.music.eq.audio import com.metrolist.music.eq.data.FilterType import kotlin.math.PI import kotlin.math.cos import kotlin.math.pow import kotlin.math.sin import kotlin.math.sqrt /** * Biquad filter implementation for EQ * Supports peaking (PK), low-shelf (LSC), and high-shelf (HSC) filters * Based on Robert Bristow-Johnson's Audio EQ Cookbook */ class BiquadFilter( private val sampleRate: Int, private val frequency: Double, private val gain: Double, private val q: Double = 1.41, private val filterType: FilterType = FilterType.PK ) { // Filter coefficients private var a0 = 0.0 private var a1 = 0.0 private var a2 = 0.0 private var b0 = 0.0 private var b1 = 0.0 private var b2 = 0.0 // State variables for filtering (per channel) private var x1L = 0.0 private var x2L = 0.0 private var y1L = 0.0 private var y2L = 0.0 private var x1R = 0.0 private var x2R = 0.0 private var y1R = 0.0 private var y2R = 0.0 init { calculateCoefficients() } /** * Calculate biquad filter coefficients based on filter type * Based on Robert Bristow-Johnson's Audio EQ Cookbook */ private fun calculateCoefficients() { when (filterType) { FilterType.PK -> calculatePeakingCoefficients() FilterType.LSC -> calculateLowShelfCoefficients() FilterType.HSC -> calculateHighShelfCoefficients() else -> { // Handle any unexpected filter type calculatePeakingCoefficients() } } } /** * Calculate peaking EQ coefficients (PK) * Boosts or cuts around a center frequency */ private fun calculatePeakingCoefficients() { val A = 10.0.pow(gain / 40.0) // Gain in linear scale val omega = 2.0 * PI * frequency / sampleRate val sinOmega = sin(omega) val cosOmega = cos(omega) val alpha = sinOmega / (2.0 * q) // Peaking EQ coefficients b0 = 1.0 + alpha * A b1 = -2.0 * cosOmega b2 = 1.0 - alpha * A a0 = 1.0 + alpha / A a1 = -2.0 * cosOmega a2 = 1.0 - alpha / A // Normalize coefficients b0 /= a0 b1 /= a0 b2 /= a0 a1 /= a0 a2 /= a0 a0 = 1.0 } /** * Calculate low-shelf coefficients (LSC) * Boosts or cuts frequencies below the cutoff frequency */ private fun calculateLowShelfCoefficients() { val A = sqrt(10.0.pow(gain / 20.0)) // Gain amplitude val omega = 2.0 * PI * frequency / sampleRate val sinOmega = sin(omega) val cosOmega = cos(omega) val S = 1.0 // Shelf slope parameter (could be made adjustable) val alpha = sinOmega / 2.0 * sqrt((A + 1.0 / A) * (1.0 / S - 1.0) + 2.0) val sqrtA = sqrt(A) // Low-shelf coefficients val aPlusOne = A + 1.0 val aMinusOne = A - 1.0 val twoSqrtAAlpha = 2.0 * sqrtA * alpha b0 = A * (aPlusOne - aMinusOne * cosOmega + twoSqrtAAlpha) b1 = 2.0 * A * (aMinusOne - aPlusOne * cosOmega) b2 = A * (aPlusOne - aMinusOne * cosOmega - twoSqrtAAlpha) a0 = aPlusOne + aMinusOne * cosOmega + twoSqrtAAlpha a1 = -2.0 * (aMinusOne + aPlusOne * cosOmega) a2 = aPlusOne + aMinusOne * cosOmega - twoSqrtAAlpha // Normalize coefficients b0 /= a0 b1 /= a0 b2 /= a0 a1 /= a0 a2 /= a0 a0 = 1.0 } /** * Calculate high-shelf coefficients (HSC) * Boosts or cuts frequencies above the cutoff frequency */ private fun calculateHighShelfCoefficients() { val A = sqrt(10.0.pow(gain / 20.0)) // Gain amplitude val omega = 2.0 * PI * frequency / sampleRate val sinOmega = sin(omega) val cosOmega = cos(omega) val S = 1.0 // Shelf slope parameter (could be made adjustable) val alpha = sinOmega / 2.0 * sqrt((A + 1.0 / A) * (1.0 / S - 1.0) + 2.0) val sqrtA = sqrt(A) // High-shelf coefficients val aPlusOne = A + 1.0 val aMinusOne = A - 1.0 val twoSqrtAAlpha = 2.0 * sqrtA * alpha b0 = A * (aPlusOne + aMinusOne * cosOmega + twoSqrtAAlpha) b1 = -2.0 * A * (aMinusOne + aPlusOne * cosOmega) b2 = A * (aPlusOne + aMinusOne * cosOmega - twoSqrtAAlpha) a0 = aPlusOne - aMinusOne * cosOmega + twoSqrtAAlpha a1 = 2.0 * (aMinusOne - aPlusOne * cosOmega) a2 = aPlusOne - aMinusOne * cosOmega - twoSqrtAAlpha // Normalize coefficients b0 /= a0 b1 /= a0 b2 /= a0 a1 /= a0 a2 /= a0 a0 = 1.0 } /** * Process a single sample (mono) */ fun processSample(input: Double): Double { val output = b0 * input + b1 * x1L + b2 * x2L - a1 * y1L - a2 * y2L // Update state x2L = x1L x1L = input y2L = y1L y1L = output return output } /** * Process stereo samples (left and right channels) */ fun processStereo(inputLeft: Double, inputRight: Double): Pair { // Left channel val outputLeft = b0 * inputLeft + b1 * x1L + b2 * x2L - a1 * y1L - a2 * y2L x2L = x1L x1L = inputLeft y2L = y1L y1L = outputLeft // Right channel val outputRight = b0 * inputRight + b1 * x1R + b2 * x2R - a1 * y1R - a2 * y2R x2R = x1R x1R = inputRight y2R = y1R y1R = outputRight return Pair(outputLeft, outputRight) } /** * Reset filter state (clears history) */ fun reset() { x1L = 0.0 x2L = 0.0 y1L = 0.0 y2L = 0.0 x1R = 0.0 x2R = 0.0 y1R = 0.0 y2R = 0.0 } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/eq/audio/CustomEqualizerAudioProcessor.kt ================================================ package com.metrolist.music.eq.audio import androidx.media3.common.C import androidx.media3.common.audio.AudioProcessor import androidx.media3.common.util.UnstableApi import com.metrolist.music.eq.data.ParametricEQ import com.metrolist.music.eq.data.ParametricEQBand import timber.log.Timber import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.pow /** * Custom audio processor for ExoPlayer that applies parametric EQ using biquad filters * Uses ParametricEQ format from AutoEQ project */ @UnstableApi @SuppressWarnings("Deprecated") class CustomEqualizerAudioProcessor : AudioProcessor { private var sampleRate = 0 private var channelCount = 0 private var encoding = C.ENCODING_INVALID private var isActive = false private var equalizerEnabled = false private var inputBuffer: ByteBuffer = EMPTY_BUFFER private var outputBuffer: ByteBuffer = EMPTY_BUFFER private var inputEnded = false private var filters: List = emptyList() private var preampGain: Double = 1.0 // Linear preamp gain multiplier private var pendingProfile: ParametricEQ? = null companion object { private const val TAG = "CustomEqualizerAudioProcessor" private val EMPTY_BUFFER: ByteBuffer = ByteBuffer.allocateDirect(0).order(ByteOrder.nativeOrder()) } /** * Apply an EQ profile */ @Synchronized fun applyProfile(parametricEQ: ParametricEQ) { if (sampleRate == 0) { // Audio processor not configured yet, store as pending Timber.tag(TAG) .d("Audio processor not configured yet. Storing profile as pending with ${parametricEQ.bands.size} bands") pendingProfile = parametricEQ return } // Convert preamp from dB to linear gain preampGain = 10.0.pow(parametricEQ.preamp / 20.0) createFilters(parametricEQ.bands) equalizerEnabled = true // Reset filter states to ensure clean transition filters.forEach { it.reset() } Timber.tag(TAG) .d("Applied EQ profile with ${filters.size} bands and ${parametricEQ.preamp} dB preamp") } /** * Disable the equalizer */ @Synchronized fun disable() { equalizerEnabled = false filters = emptyList() preampGain = 1.0 pendingProfile = null Timber.tag(TAG).d("Equalizer disabled") } /** * Check if equalizer is enabled */ fun isEnabled(): Boolean = equalizerEnabled /** * Create biquad filters from ParametricEQ bands * Only creates filters for enabled bands below Nyquist frequency * Supports PK (peaking), LSC (low-shelf), and HSC (high-shelf) filter types */ private fun createFilters(bands: List) { if (sampleRate == 0) { Timber.tag(TAG).w("Cannot create filters: sample rate not set") return } // Filter out disabled bands and frequencies above Nyquist limit filters = bands .filter { it.enabled && it.frequency < sampleRate / 2.0 } .map { band -> BiquadFilter( sampleRate = sampleRate, frequency = band.frequency, gain = band.gain, q = band.q, filterType = band.filterType ) } Timber.tag(TAG) .d("Created ${filters.size} biquad filters from ${bands.size} bands (PK/LSC/HSC)") } override fun configure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { sampleRate = inputAudioFormat.sampleRate channelCount = inputAudioFormat.channelCount encoding = inputAudioFormat.encoding Timber.tag(TAG) .d("Configured: sampleRate=$sampleRate, channels=$channelCount, encoding=$encoding") // Apply pending profile if one exists pendingProfile?.let { profile -> preampGain = 10.0.pow(profile.preamp / 20.0) createFilters(profile.bands) equalizerEnabled = true pendingProfile = null Timber.tag(TAG) .d("Applied pending profile with ${filters.size} bands and ${profile.preamp} dB preamp") } // Only support 16-bit PCM stereo/mono if (encoding != C.ENCODING_PCM_16BIT || channelCount > 2) { val exception = AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) throw exception // Rethrow, unsupported } isActive = true return inputAudioFormat } override fun isActive(): Boolean = isActive override fun queueInput(inputBuffer: ByteBuffer) { if (!equalizerEnabled || filters.isEmpty()) { // Passthrough mode - directly use input as output val remaining = inputBuffer.remaining() if (remaining == 0) return // Ensure output buffer is large enough if (outputBuffer.capacity() < remaining) { outputBuffer = ByteBuffer.allocateDirect(remaining).order(ByteOrder.nativeOrder()) } else { outputBuffer.clear() } outputBuffer.put(inputBuffer) outputBuffer.flip() return } val inputSize = inputBuffer.remaining() if (inputSize == 0) { return } // Ensure we have our own output buffer (reuse if possible to avoid allocations) // Note: We MUST NOT use inputBuffer as outputBuffer if we modify it if (outputBuffer === EMPTY_BUFFER || outputBuffer === inputBuffer) { // Need new buffer - was empty or same as input outputBuffer = ByteBuffer.allocateDirect(inputSize).order(ByteOrder.nativeOrder()) } else if (outputBuffer.capacity() < inputSize) { // Need larger buffer outputBuffer = ByteBuffer.allocateDirect(inputSize).order(ByteOrder.nativeOrder()) } else { // Reuse existing buffer (most common path) outputBuffer.clear() } // Process audio samples when (encoding) { C.ENCODING_PCM_16BIT -> { // Ensure the output buffer is ready to receive data // We don't set limit() here because putShort will advance position processAudioBuffer16Bit(inputBuffer, outputBuffer) } else -> { // Unsupported format, passthrough outputBuffer.put(inputBuffer) } } outputBuffer.flip() // inputBuffer position is already updated by processAudioBuffer16Bit/put } /** * Process 16-bit PCM audio through all biquad filters */ private fun processAudioBuffer16Bit(input: ByteBuffer, output: ByteBuffer) { // Ensure we are reading from the current position // Input is ready to be read from position() to limit() // Output is ready to be written to from position() val sampleCount = input.remaining() / 2 // 2 bytes per 16-bit sample repeat(sampleCount / channelCount) { when (channelCount) { 1 -> { // Mono val sample = input.getShort().toDouble() / 32768.0 // Normalize to [-1, 1] var processed = sample // Apply all filters in series for (filter in filters) { processed = filter.processSample(processed) } // Apply preamp gain processed *= preampGain // Clamp and convert back to 16-bit val outputSample = (processed * 32768.0).coerceIn(-32768.0, 32767.0).toInt().toShort() output.putShort(outputSample) } 2 -> { // Stereo val leftSample = input.getShort().toDouble() / 32768.0 val rightSample = input.getShort().toDouble() / 32768.0 var processedLeft = leftSample var processedRight = rightSample // Apply all filters in series for (filter in filters) { val (left, right) = filter.processStereo(processedLeft, processedRight) processedLeft = left processedRight = right } // Apply preamp gain processedLeft *= preampGain processedRight *= preampGain // Clamp and convert back to 16-bit val outputLeft = (processedLeft * 32768.0).coerceIn(-32768.0, 32767.0).toInt().toShort() val outputRight = (processedRight * 32768.0).coerceIn(-32768.0, 32767.0).toInt().toShort() output.putShort(outputLeft) output.putShort(outputRight) } else -> { // Should not happen as configure rejects > 2 channels repeat(channelCount) { output.putShort(input.getShort()) } } } } } override fun getOutput(): ByteBuffer { // Return output buffer ready for reading (already flipped in queueInput) val buffer = outputBuffer outputBuffer = EMPTY_BUFFER return buffer } override fun isEnded(): Boolean { return inputEnded && outputBuffer.remaining() == 0 } @Deprecated("Deprecated in Java") override fun flush() { outputBuffer = EMPTY_BUFFER inputEnded = false // Reset filter states filters.forEach { it.reset() } } override fun reset() { @Suppress("DEPRECATION") flush() inputBuffer = EMPTY_BUFFER sampleRate = 0 channelCount = 0 encoding = C.ENCODING_INVALID isActive = false filters.forEach { it.reset() } } override fun queueEndOfStream() { inputEnded = true } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/eq/data/EQProfileRepository.kt ================================================ package com.metrolist.music.eq.data import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton /** * Saved EQ Profile with metadata */ @Serializable data class SavedEQProfile( val id: String, // Unique identifier val name: String, // Display name val deviceModel: String, // e.g., "Sony WH-1000XM4" val bands: List, // EQ bands val preamp: Double = 0.0, // Preamp gain in dB val isCustom: Boolean = false, // Whether this is a custom imported profile val isActive: Boolean = false, // Whether this profile is currently active val addedTimestamp: Long = System.currentTimeMillis() ) /** * Repository for managing EQ profiles * Handles saving, loading, and activating EQ profiles */ @Singleton class EQProfileRepository @Inject constructor( @ApplicationContext private val context: Context ) { private val prefs: SharedPreferences = context.getSharedPreferences( "nanosonic_eq_profiles", Context.MODE_PRIVATE ) private val json = Json { ignoreUnknownKeys = true prettyPrint = true } private val _profiles = MutableStateFlow>(emptyList()) val profiles: StateFlow> = _profiles.asStateFlow() private val _activeProfile = MutableStateFlow(null) val activeProfile: StateFlow = _activeProfile.asStateFlow() companion object { private const val KEY_PROFILES = "eq_profiles" private const val KEY_ACTIVE_PROFILE_ID = "active_profile_id" } init { loadProfiles() } /** * Load all saved profiles from SharedPreferences */ private fun loadProfiles() { try { val profilesJson = prefs.getString(KEY_PROFILES, null) if (profilesJson != null) { val loadedProfiles = json.decodeFromString>(profilesJson) _profiles.value = loadedProfiles // Load active profile val activeId = prefs.getString(KEY_ACTIVE_PROFILE_ID, null) _activeProfile.value = loadedProfiles.find { it.id == activeId } } } catch (e: Exception) { println("Error loading EQ profiles: ${e.message}") _profiles.value = emptyList() _activeProfile.value = null } } /** * Save a new EQ profile */ suspend fun saveProfile(profile: SavedEQProfile) = withContext(Dispatchers.IO) { val currentProfiles = _profiles.value.toMutableList() // Check if profile with same ID already exists val existingIndex = currentProfiles.indexOfFirst { it.id == profile.id } if (existingIndex >= 0) { // Update existing profile currentProfiles[existingIndex] = profile } else { // Add new profile currentProfiles.add(profile) } // Save to SharedPreferences val profilesJson = json.encodeToString>(currentProfiles) prefs.edit { putString(KEY_PROFILES, profilesJson) } _profiles.value = currentProfiles } /** * Delete a profile */ suspend fun deleteProfile(profileId: String) = withContext(Dispatchers.IO) { val currentProfiles = _profiles.value.toMutableList() currentProfiles.removeAll { it.id == profileId } val profilesJson = json.encodeToString>(currentProfiles) prefs.edit { putString(KEY_PROFILES, profilesJson) } // If deleted profile was active, clear active profile if (_activeProfile.value?.id == profileId) { _activeProfile.value = null prefs.edit { remove(KEY_ACTIVE_PROFILE_ID) } } _profiles.value = currentProfiles } /** * Set a profile as active (only one profile can be active at a time) * Pass null to deactivate all profiles */ suspend fun setActiveProfile(profileId: String?) = withContext(Dispatchers.IO) { val currentProfiles = _profiles.value if (profileId == null) { // Deactivate all profiles _activeProfile.value = null prefs.edit { remove(KEY_ACTIVE_PROFILE_ID) } } else { val profile = currentProfiles.find { it.id == profileId } _activeProfile.value = profile prefs.edit { putString(KEY_ACTIVE_PROFILE_ID, profileId) } } } /** * Get all saved profiles */ fun getAllProfiles(): List { return _profiles.value } /** * Get active profile */ fun getActiveProfile(): SavedEQProfile? { return _activeProfile.value } /** * Import a custom EQ profile from ParametricEQ data */ suspend fun importCustomProfile( name: String, parametricEQ: ParametricEQ ) = withContext(Dispatchers.IO) { // Generate unique ID for custom profile val id = "custom_${System.currentTimeMillis()}_${name.hashCode()}" val customProfile = SavedEQProfile( id = id, name = name, deviceModel = name, bands = parametricEQ.bands, // Already ParametricEQBand preamp = parametricEQ.preamp, isActive = false, isCustom = true // Ensure this flag is set! ) saveProfile(customProfile) } /** * Get profiles sorted by type: AutoEQ first, then custom profiles * Within each group, sort by timestamp (newest first) */ fun getSortedProfiles(): List { // Only custom profiles are supported now return _profiles.value .filter { it.isCustom } .sortedByDescending { it.addedTimestamp } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/eq/data/FilterType.kt ================================================ package com.metrolist.music.eq.data import kotlinx.serialization.Serializable @Serializable enum class FilterType { /** Peaking filter - boosts or cuts around a center frequency */ PK, /** Low-shelf filter - affects frequencies below the cutoff */ LSC, /** High-shelf filter - affects frequencies above the cutoff */ HSC, /** Low-pass filter - attenuates frequencies above the cutoff */ LPQ, /** High-pass filter - attenuates frequencies below the cutoff */ HPQ } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/eq/data/ParametricEQ.kt ================================================ package com.metrolist.music.eq.data import kotlinx.serialization.Serializable /** * Represents a single parametric EQ filter/band * Supports APO Parametric EQ filters */ @Serializable data class ParametricEQBand( val frequency: Double, // Center frequency in Hz val gain: Double, // Gain in dB val q: Double = 1.41, // Q factor (bandwidth) - default to sqrt(2) val filterType: FilterType = FilterType.PK, // Filter type val enabled: Boolean = true // Whether this band is active ) /** * Represents a complete parametric EQ configuration for a headphone * Parsed from AutoEQ preset files (...ParametricEQ.txt) */ @Serializable data class ParametricEQ( val preamp: Double, // Preamp/gain in dB (to prevent clipping) val bands: List, // List of EQ bands val metadata: Map = emptyMap() // Additional metadata from file ) { companion object { const val MAX_BANDS = 20 // Maximum bands supported by the implementation } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/eq/data/ParametricEQParser.kt ================================================ package com.metrolist.music.eq.data import java.io.File /** * Parser for AutoEq ParametricEQ.txt files. * These files contain parametric EQ settings that can be applied to audio devices. * * File format: * Preamp: -5.2 dB * Filter 1: ON LSC Fc 105 Hz Gain 8.8 dB Q 0.70 * Filter 2: ON PK Fc 70 Hz Gain -6.7 dB Q 0.29 * ... * * Where: * - LSC = Low Shelf * - HSC = High Shelf * - PK = Peaking filter * - LPQ = Low Pass * - HPQ = High Pass */ object ParametricEQParser { /** * Parse a ParametricEQ file */ fun parseFile(file: File): ParametricEQ { if (!file.exists()) { throw IllegalArgumentException("File does not exist: ${file.absolutePath}") } return parseText(file.readText()) } /** * Parse a ParametricEQ file from a path string */ fun parseFile(filePath: String): ParametricEQ { return parseFile(File(filePath)) } /** * Parse ParametricEQ text content */ fun parseText(content: String): ParametricEQ { val lines = content.lines() var preamp = 0.0 val bands = mutableListOf() val metadata = mutableMapOf() for (line in lines) { val trimmedLine = line.trim() if (trimmedLine.isEmpty()) continue when { // Parse preamp line: "Preamp: -5.2 dB" trimmedLine.startsWith("Preamp:", ignoreCase = true) -> { preamp = parsePreamp(trimmedLine) } // Parse filter line: "Filter 1: ON LSC Fc 105 Hz Gain 8.8 dB Q 0.70" trimmedLine.startsWith("Filter", ignoreCase = true) -> { val band = parseFilterLine(trimmedLine) if (band != null) { bands.add(band) } } // Store other lines as metadata else -> { val parts = trimmedLine.split(":", limit = 2) if (parts.size == 2) { metadata[parts[0].trim()] = parts[1].trim() } } } } return ParametricEQ( preamp = preamp, bands = bands, metadata = metadata ) } /** * Parse the preamp line * Example: "Preamp: -5.2 dB" */ private fun parsePreamp(line: String): Double { val regex = Regex("""Preamp:\s*([-+]?\d+\.?\d*)\s*dB""", RegexOption.IGNORE_CASE) val match = regex.find(line) return match?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0 } /** * Parse a filter line * Example: "Filter 1: ON LSC Fc 105 Hz Gain 8.8 dB Q 0.70" */ private fun parseFilterLine(line: String): ParametricEQBand? { try { // Check if filter is ON if (!line.contains("ON", ignoreCase = true)) { return null } // Extract filter type (LSC, HSC, PK, LPQ, HPQ) val filterType = parseFilterType(line) ?: return null // Extract frequency: "Fc 105 Hz" val frequency = parseValue(line, "Fc", "Hz") ?: return null // Extract gain: "Gain 8.8 dB" val gain = parseValue(line, "Gain", "dB") ?: return null // Extract Q factor: "Q 0.70" val q = parseValue(line, "Q", null) ?: return null return ParametricEQBand( filterType = filterType, frequency = frequency, gain = gain, q = q ) } catch (e: Exception) { println("Warning: Failed to parse filter line: $line") println("Error: ${e.message}") return null } } /** * Parse filter type from line */ private fun parseFilterType(line: String): FilterType? { return when { line.contains("LSC", ignoreCase = true) -> FilterType.LSC line.contains("HSC", ignoreCase = true) -> FilterType.HSC line.contains("PK", ignoreCase = true) -> FilterType.PK line.contains("LPQ", ignoreCase = true) -> FilterType.LPQ line.contains("HPQ", ignoreCase = true) -> FilterType.HPQ else -> null } } /** * Parse a numeric value from the line * Example: parseValue("... Fc 105 Hz ...", "Fc", "Hz") -> 105.0 */ private fun parseValue(line: String, keyword: String, unit: String?): Double? { val unitPattern = if (unit != null) "\\s*$unit" else "" val regex = Regex("""$keyword\s+([-+]?\d+\.?\d*)$unitPattern""", RegexOption.IGNORE_CASE) val match = regex.find(line) return match?.groupValues?.get(1)?.toDoubleOrNull() } /** * Convert ParametricEQ to a human-readable string */ fun toString(eq: ParametricEQ): String { val sb = StringBuilder() sb.appendLine("Preamp: ${eq.preamp} dB") eq.bands.forEachIndexed { index, band -> sb.appendLine( "Filter ${index + 1}: ${band.filterType} Fc ${band.frequency} Hz " + "Gain ${band.gain} dB Q ${band.q}" ) } return sb.toString() } /** * Format ParametricEQ for export to file */ fun toFileFormat(eq: ParametricEQ): String { val sb = StringBuilder() sb.appendLine("Preamp: ${eq.preamp} dB") eq.bands.forEachIndexed { index, band -> sb.appendLine( "Filter ${index + 1}: ON ${band.filterType} " + "Fc ${band.frequency.toInt()} Hz " + "Gain ${band.gain} dB " + "Q ${String.format("%.2f", band.q)}" ) } return sb.toString() } /** * Validate a ParametricEQ profile * Returns a list of validation error messages (empty list if valid) */ fun validate(eq: ParametricEQ): List { val errors = mutableListOf() // Validate preamp if (eq.preamp < -50.0 || eq.preamp > 50.0) { errors.add("Preamp value ${eq.preamp} dB is out of range (-50 to +50 dB)") } // Validate bands exist if (eq.bands.isEmpty()) { errors.add("EQ profile must have at least one band") } // Validate number of bands if (eq.bands.size > ParametricEQ.MAX_BANDS) { errors.add("EQ profile has ${eq.bands.size} bands, maximum is ${ParametricEQ.MAX_BANDS}") } // Validate each band eq.bands.forEachIndexed { index, band -> // Validate frequency if (band.frequency <= 0.0 || band.frequency > 100000.0) { errors.add("Band ${index + 1}: Frequency ${band.frequency} Hz is out of range (1 to 100000 Hz)") } // Validate gain if (band.gain < -30.0 || band.gain > 30.0) { errors.add("Band ${index + 1}: Gain ${band.gain} dB is out of range (-30 to +30 dB)") } // Validate Q factor if (band.q <= 0.0 || band.q > 20.0) { errors.add("Band ${index + 1}: Q factor ${band.q} is out of range (0.01 to 20)") } } return errors } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/extensions/ContextExt.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.extensions import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities import com.metrolist.innertube.utils.parseCookieString import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.YtmSyncKey import com.metrolist.music.utils.dataStore import com.metrolist.music.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 } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/extensions/CoroutineExt.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 { _, _ -> } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/extensions/FileExt.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/extensions/ListExt.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.extensions import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.Song 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 } // Extension function to filter explicit content for local Song entities fun List.filterExplicit(enabled: Boolean = true) = if (enabled) { filter { !it.song.explicit } } else { this } // Extension function to filter video songs for local Song entities fun List.filterVideoSongs(enabled: Boolean = true) = if (enabled) { filter { !it.song.isVideo } } else { this } // Extension function to filter explicit content for local Album entities fun List.filterExplicitAlbums(enabled: Boolean = true) = if (enabled) { filter { !it.album.explicit } } else { this } // Extension function to filter YouTube Shorts playlist fun List.filterYoutubeShorts(enabled: Boolean = false) = if (enabled) { filterNot { it.playlist.browseId?.startsWith("SS") == true } } else { this } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/extensions/MediaItemExt.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.extensions import android.os.Bundle import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC import com.metrolist.innertube.models.SongItem import com.metrolist.music.db.entities.Song import com.metrolist.music.models.MediaMetadata import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.ui.utils.resize 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(orderedArtists.joinToString { it.name }) .setArtist(orderedArtists.joinToString { it.name }) .setArtworkUri(song.thumbnailUrl?.toUri()) .setAlbumTitle(song.albumName) .setAlbumArtist(orderedArtists.firstOrNull()?.name) .setDisplayTitle(song.title) .setMediaType(MEDIA_TYPE_MUSIC) .setIsBrowsable(false) .setIsPlayable(true) .setExtras(Bundle().apply { putString("artwork_uri", song.thumbnailUrl) }) .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.resize(544, 544).toUri()) .setAlbumTitle(album?.name) .setAlbumArtist(artists.firstOrNull()?.name) .setDisplayTitle(title) .setMediaType(MEDIA_TYPE_MUSIC) .setIsBrowsable(false) .setIsPlayable(true) .setExtras(Bundle().apply { putString("artwork_uri", thumbnail.resize(544, 544)) }) .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) .setAlbumArtist(artists.firstOrNull()?.name) .setDisplayTitle(title) .setMediaType(MEDIA_TYPE_MUSIC) .setIsBrowsable(false) .setIsPlayable(true) .setExtras(Bundle().apply { thumbnailUrl?.let { putString("artwork_uri", it) } }) .build() ) .build() ================================================ FILE: app/src/main/kotlin/com/metrolist/music/extensions/PlayerExt.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 androidx.media3.common.TrackSelectionParameters import com.metrolist.music.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 } fun Player.setOffloadEnabled(enabled: Boolean) { trackSelectionParameters = trackSelectionParameters.buildUpon() .setAudioOffloadPreferences( TrackSelectionParameters.AudioOffloadPreferences .Builder() .setAudioOffloadMode( if (enabled) { TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED } else { TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED } ) .build() ).build() } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/extensions/QueueExt.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.extensions import com.metrolist.music.models.MediaMetadata import com.metrolist.music.models.PersistQueue import com.metrolist.music.models.QueueData import com.metrolist.music.models.QueueType import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.playback.queues.LocalAlbumRadio import com.metrolist.music.playback.queues.Queue import com.metrolist.music.playback.queues.YouTubeAlbumRadio import com.metrolist.music.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 ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/extensions/StringExt.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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()) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/extensions/UtilExt.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.extensions fun tryOrNull(block: () -> T): T? = try { block() } catch (e: Exception) { null } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/listentogether/ListenTogetherActionReceiver.kt ================================================ package com.metrolist.music.listentogether import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.core.app.NotificationManagerCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class ListenTogetherActionReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val client = ListenTogetherClient.getInstance() ?: return val notifId = intent.getIntExtra(ListenTogetherClient.EXTRA_NOTIFICATION_ID, 0) // Cancel the notification immediately NotificationManagerCompat.from(context).cancel(notifId) when (intent.action) { ListenTogetherClient.ACTION_APPROVE_JOIN -> { val userId = intent.getStringExtra(ListenTogetherClient.EXTRA_USER_ID) ?: return CoroutineScope(Dispatchers.IO).launch { client.approveJoin(userId) } } ListenTogetherClient.ACTION_REJECT_JOIN -> { val userId = intent.getStringExtra(ListenTogetherClient.EXTRA_USER_ID) ?: return CoroutineScope(Dispatchers.IO).launch { client.rejectJoin(userId, null) } } ListenTogetherClient.ACTION_APPROVE_SUGGESTION -> { val suggestionId = intent.getStringExtra(ListenTogetherClient.EXTRA_SUGGESTION_ID) ?: return CoroutineScope(Dispatchers.IO).launch { client.approveSuggestion(suggestionId) } } ListenTogetherClient.ACTION_REJECT_SUGGESTION -> { val suggestionId = intent.getStringExtra(ListenTogetherClient.EXTRA_SUGGESTION_ID) ?: return CoroutineScope(Dispatchers.IO).launch { client.rejectSuggestion(suggestionId, null) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/listentogether/ListenTogetherClient.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.listentogether import android.Manifest import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.PowerManager import android.widget.Toast import androidx.annotation.RequiresPermission import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService import androidx.datastore.preferences.core.edit import com.metrolist.music.R import com.metrolist.music.constants.ListenTogetherAutoApprovalKey import com.metrolist.music.constants.ListenTogetherAutoApproveSuggestionsKey import com.metrolist.music.constants.ListenTogetherIsHostKey import com.metrolist.music.constants.ListenTogetherRoomCodeKey import com.metrolist.music.constants.ListenTogetherServerUrlKey import com.metrolist.music.constants.ListenTogetherSessionTimestampKey import com.metrolist.music.constants.ListenTogetherSessionTokenKey import com.metrolist.music.constants.ListenTogetherUserIdKey import com.metrolist.music.utils.NetworkConnectivityObserver import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import timber.log.Timber import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton /** * Connection state for the Listen Together feature */ enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING, ERROR, } /** * Room role for the current user */ enum class RoomRole { HOST, GUEST, NONE, } /** * Log entry for debugging */ data class LogEntry( val timestamp: String, val level: LogLevel, val message: String, val details: String? = null, ) enum class LogLevel { INFO, WARNING, ERROR, DEBUG, } /** * Pending action to execute when connected */ sealed class PendingAction { data class CreateRoom( val username: String, ) : PendingAction() data class JoinRoom( val roomCode: String, val username: String, ) : PendingAction() } /** * Event types for the Listen Together client */ sealed class ListenTogetherEvent { // Connection events data class Connected( val userId: String, ) : ListenTogetherEvent() data object Disconnected : ListenTogetherEvent() data class ConnectionError( val error: String, ) : ListenTogetherEvent() data class Reconnecting( val attempt: Int, val maxAttempts: Int, ) : ListenTogetherEvent() // Room events data class RoomCreated( val roomCode: String, val userId: String, ) : ListenTogetherEvent() data class JoinRequestReceived( val userId: String, val username: String, ) : ListenTogetherEvent() data class JoinApproved( val roomCode: String, val userId: String, val state: RoomState, ) : ListenTogetherEvent() data class JoinRejected( val reason: String, ) : ListenTogetherEvent() data class UserJoined( val userId: String, val username: String, ) : ListenTogetherEvent() data class UserLeft( val userId: String, val username: String, ) : ListenTogetherEvent() data class HostChanged( val newHostId: String, val newHostName: String, ) : ListenTogetherEvent() data class Kicked( val reason: String, ) : ListenTogetherEvent() data class Reconnected( val roomCode: String, val userId: String, val state: RoomState, val isHost: Boolean, ) : ListenTogetherEvent() data class UserReconnected( val userId: String, val username: String, ) : ListenTogetherEvent() data class UserDisconnected( val userId: String, val username: String, ) : ListenTogetherEvent() // Playback events data class PlaybackSync( val action: PlaybackActionPayload, ) : ListenTogetherEvent() data class BufferWait( val trackId: String, val waitingFor: List, ) : ListenTogetherEvent() data class BufferComplete( val trackId: String, ) : ListenTogetherEvent() data class SyncStateReceived( val state: SyncStatePayload, ) : ListenTogetherEvent() // Error events data class ServerError( val code: String, val message: String, ) : ListenTogetherEvent() } /** * WebSocket client for Listen Together feature */ @Singleton class ListenTogetherClient @Inject constructor( private val context: Context, ) { companion object { private const val TAG = "ListenTogether" private val DEFAULT_SERVER_URL = ListenTogetherServers.defaultServerUrl private const val MAX_RECONNECT_ATTEMPTS = 15 // Increased from 5 to 15 private const val INITIAL_RECONNECT_DELAY_MS = 1000L // Start at 1 second private const val MAX_RECONNECT_DELAY_MS = 120000L // Cap at 2 minutes private const val PING_INTERVAL_MS = 25000L private const val MAX_LOG_ENTRIES = 500 private const val SESSION_GRACE_PERIOD_MS = 10 * 60 * 1000L // 10 minutes // Notification constants private const val NOTIFICATION_CHANNEL_ID = "listen_together_channel" const val ACTION_APPROVE_JOIN = "com.metrolist.music.LISTEN_TOGETHER_APPROVE_JOIN" const val ACTION_REJECT_JOIN = "com.metrolist.music.LISTEN_TOGETHER_REJECT_JOIN" const val ACTION_APPROVE_SUGGESTION = "com.metrolist.music.LISTEN_TOGETHER_APPROVE_SUGGESTION" const val ACTION_REJECT_SUGGESTION = "com.metrolist.music.LISTEN_TOGETHER_REJECT_SUGGESTION" const val EXTRA_USER_ID = "extra_user_id" const val EXTRA_SUGGESTION_ID = "extra_suggestion_id" const val EXTRA_NOTIFICATION_ID = "extra_notification_id" @Volatile private var instance: ListenTogetherClient? = null fun getInstance(): ListenTogetherClient? = instance fun setInstance(client: ListenTogetherClient) { instance = client } } // Initialize scope early before init block since it's used in observeNetworkChanges() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // State flows - initialized before init block to avoid NullPointerException when accessing log() private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) val connectionState: StateFlow = _connectionState.asStateFlow() private val _roomState = MutableStateFlow(null) val roomState: StateFlow = _roomState.asStateFlow() private val _role = MutableStateFlow(RoomRole.NONE) val role: StateFlow = _role.asStateFlow() private val _userId = MutableStateFlow(null) val userId: StateFlow = _userId.asStateFlow() private val _pendingJoinRequests = MutableStateFlow>(emptyList()) val pendingJoinRequests: StateFlow> = _pendingJoinRequests.asStateFlow() private val _bufferingUsers = MutableStateFlow>(emptyList()) val bufferingUsers: StateFlow> = _bufferingUsers.asStateFlow() // Suggestions: pending items visible to host private val _pendingSuggestions = MutableStateFlow>(emptyList()) val pendingSuggestions: StateFlow> = _pendingSuggestions.asStateFlow() // Blocked usernames (internal list for privacy) private val _blockedUsernames = MutableStateFlow>(emptySet()) val blockedUsernames: StateFlow> = _blockedUsernames.asStateFlow() private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _logs.asStateFlow() // Event flow private val _events = MutableSharedFlow() val events: SharedFlow = _events.asSharedFlow() init { setInstance(this) ensureNotificationChannel() // Load persisted session info asynchronously after construction to avoid calling log() before flows are initialized CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { loadPersistedSession() observeNetworkChanges() } } /** * Observe network changes to trigger reconnections */ private fun observeNetworkChanges() { scope.launch { try { val observer = connectivityObserver ?: return@launch observer.networkStatus.collect { available: Boolean -> val previous = isNetworkAvailable isNetworkAvailable = available if (available && !previous) { log(LogLevel.INFO, "Network restored, checking if reconnection needed") // Reset attempts when network is restored to allow a fresh set of retries if (_connectionState.value == ConnectionState.ERROR || _connectionState.value == ConnectionState.DISCONNECTED ) { if (sessionToken != null || _roomState.value != null || pendingAction != null) { log(LogLevel.INFO, "Network restored, triggering reconnection") reconnectAttempts = 0 // Reset attempts for a fresh start connect() } } } else if (!available && previous) { log(LogLevel.WARNING, "Network lost") } } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error observing network changes") } } } /** * Load persisted session information from storage */ private fun loadPersistedSession() { try { val token = context.dataStore.get(ListenTogetherSessionTokenKey, "") val roomCode = context.dataStore.get(ListenTogetherRoomCodeKey, "") val userId = context.dataStore.get(ListenTogetherUserIdKey, "") val isHost = context.dataStore.get(ListenTogetherIsHostKey, false) val timestamp = context.dataStore.get(ListenTogetherSessionTimestampKey, 0L) // Check if session is still valid (within grace period) if (token.isNotEmpty() && roomCode.isNotEmpty() && (System.currentTimeMillis() - timestamp < SESSION_GRACE_PERIOD_MS) ) { sessionToken = token storedRoomCode = roomCode _userId.value = userId.ifEmpty { null } wasHost = isHost sessionStartTime = timestamp log(LogLevel.INFO, "Loaded persisted session", "Room: $roomCode, Host: $isHost") } else if (token.isNotEmpty()) { log(LogLevel.WARNING, "Session expired", "Age: ${System.currentTimeMillis() - timestamp}ms") clearPersistedSession() } } catch (e: Exception) { log(LogLevel.ERROR, "Failed to load persisted session", e.message) } // Also load blocked usernames loadBlockedUsernames() // Migrate old server URL to new one migrateServerUrl() } /** * Load blocked usernames from storage */ private fun loadBlockedUsernames() { try { val blockedJson = context.dataStore.get(com.metrolist.music.constants.ListenTogetherBlockedUsersKey, "") val blockedList = if (blockedJson.isNotEmpty()) { json.decodeFromString>(blockedJson) } else { emptyList() } _blockedUsernames.value = blockedList.toSet() } catch (e: Exception) { log(LogLevel.ERROR, "Failed to load blocked usernames", e.message) _blockedUsernames.value = emptySet() } } /** * Save blocked usernames to storage */ private suspend fun saveBlockedUsernames() { try { val blockedJson = json.encodeToString(_blockedUsernames.value.toList()) context.dataStore.edit { preferences -> preferences[com.metrolist.music.constants.ListenTogetherBlockedUsersKey] = blockedJson } } catch (e: Exception) { log(LogLevel.ERROR, "Failed to save blocked usernames", e.message) } } /** * Migrate old server URL to new one if needed */ private fun migrateServerUrl() { try { val oldServerUrl = "wss://metroserver.meowery.eu/ws" val currentUrl = context.dataStore.get(ListenTogetherServerUrlKey, DEFAULT_SERVER_URL) if (currentUrl == oldServerUrl) { log(LogLevel.INFO, "Migrating server URL", "Old: $oldServerUrl -> New: $DEFAULT_SERVER_URL") scope.launch { context.dataStore.edit { preferences -> preferences[ListenTogetherServerUrlKey] = DEFAULT_SERVER_URL } } } } catch (e: Exception) { log(LogLevel.ERROR, "Failed to migrate server URL", e.message) } } /** * Save current session information to persistent storage */ private fun savePersistedSession() { try { scope.launch { context.dataStore.edit { preferences -> if (sessionToken != null) { preferences[ListenTogetherSessionTokenKey] = sessionToken!! preferences[ListenTogetherRoomCodeKey] = storedRoomCode ?: "" preferences[ListenTogetherUserIdKey] = _userId.value ?: "" preferences[ListenTogetherIsHostKey] = wasHost preferences[ListenTogetherSessionTimestampKey] = System.currentTimeMillis() } } } } catch (e: Exception) { log(LogLevel.ERROR, "Failed to save persisted session", e.message) } } /** * Clear persisted session information */ private fun clearPersistedSession() { try { scope.launch { context.dataStore.edit { preferences -> preferences.remove(ListenTogetherSessionTokenKey) preferences.remove(ListenTogetherRoomCodeKey) preferences.remove(ListenTogetherUserIdKey) preferences.remove(ListenTogetherIsHostKey) preferences.remove(ListenTogetherSessionTimestampKey) } } } catch (e: Exception) { log(LogLevel.ERROR, "Failed to clear persisted session", e.message) } } private val json = Json { ignoreUnknownKeys = true encodeDefaults = true } // Message codec - uses Protobuf with compression enabled private val codec = MessageCodec(true) private var webSocket: WebSocket? = null private var pingJob: Job? = null private var reconnectAttempts = 0 // Session info for reconnection private var sessionToken: String? = null private var storedUsername: String? = null private var storedRoomCode: String? = null private var wasHost: Boolean = false private var sessionStartTime: Long = 0 // Pending actions to execute when connected private var pendingAction: PendingAction? = null // Wake lock to keep connection alive when in a room private var wakeLock: PowerManager.WakeLock? = null // Track notification IDs for join requests to dismiss them from both UI and notification actions private val joinRequestNotifications = mutableMapOf() // Track notification IDs for suggestions to dismiss them similarly private val suggestionNotifications = mutableMapOf() // Network connectivity monitoring - use lazy to avoid initialization order issues private val connectivityObserver: NetworkConnectivityObserver? by lazy { try { NetworkConnectivityObserver(context) } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to create NetworkConnectivityObserver") null } } private var isNetworkAvailable = try { connectivityObserver?.isCurrentlyConnected() ?: true } catch (e: Exception) { true } private val client = OkHttpClient .Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .pingInterval(60, TimeUnit.SECONDS) // Match server ping interval .build() private fun getServerUrl(): String = context.dataStore.get(ListenTogetherServerUrlKey, DEFAULT_SERVER_URL) /** * Calculate exponential backoff delay with jitter */ private fun calculateBackoffDelay(attempt: Int): Long { val exponentialDelay = INITIAL_RECONNECT_DELAY_MS * (2 shl (minOf(attempt - 1, 4))) val cappedDelay = minOf(exponentialDelay, MAX_RECONNECT_DELAY_MS) // Add 0-20% jitter to prevent thundering herd val jitter = (cappedDelay * 0.2 * Math.random()).toLong() return cappedDelay + jitter } private fun log( level: LogLevel, message: String, details: String? = null, ) { val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS")) val entry = LogEntry(timestamp, level, message, details) _logs.value = (_logs.value + entry).takeLast(MAX_LOG_ENTRIES) when (level) { LogLevel.ERROR -> Timber.tag(TAG).e("$message ${details ?: ""}") LogLevel.WARNING -> Timber.tag(TAG).w("$message ${details ?: ""}") LogLevel.DEBUG -> Timber.tag(TAG).d("$message ${details ?: ""}") LogLevel.INFO -> Timber.tag(TAG).i("$message ${details ?: ""}") } } fun clearLogs() { _logs.value = emptyList() } /** * Connect to the Listen Together server */ fun connect() { if (_connectionState.value == ConnectionState.CONNECTED || _connectionState.value == ConnectionState.CONNECTING ) { log(LogLevel.WARNING, "Already connected or connecting") return } _connectionState.value = ConnectionState.CONNECTING log(LogLevel.INFO, "Connecting to server", getServerUrl()) val request = Request .Builder() .url(getServerUrl()) .build() webSocket = client.newWebSocket( request, object : WebSocketListener() { override fun onOpen( webSocket: WebSocket, response: Response, ) { log(LogLevel.INFO, "Connected to server") _connectionState.value = ConnectionState.CONNECTED reconnectAttempts = 0 startPingJob() // Try to reconnect to previous session if we have a valid token if (sessionToken != null && storedRoomCode != null) { log(LogLevel.INFO, "Attempting to reconnect to previous session", "Room: $storedRoomCode") sendMessage(MessageTypes.RECONNECT, ReconnectPayload(sessionToken!!)) } else { // Execute any pending action executePendingAction() } } override fun onMessage( webSocket: WebSocket, bytes: okio.ByteString, ) { // Handle binary protobuf messages handleMessage(bytes.toByteArray()) } override fun onClosing( webSocket: WebSocket, code: Int, reason: String, ) { log(LogLevel.INFO, "Server closing connection", "Code: $code, Reason: $reason") webSocket.close(1000, null) } override fun onClosed( webSocket: WebSocket, code: Int, reason: String, ) { log(LogLevel.INFO, "Connection closed", "Code: $code, Reason: $reason") handleDisconnect() } override fun onFailure( webSocket: WebSocket, t: Throwable, response: Response?, ) { log(LogLevel.ERROR, "Connection failure", t.message) handleConnectionFailure(t) } }, ) } private fun executePendingAction() { val action = pendingAction ?: return pendingAction = null when (action) { is PendingAction.CreateRoom -> { log(LogLevel.INFO, "Executing pending create room", action.username) sendMessage(MessageTypes.CREATE_ROOM, CreateRoomPayload(action.username)) } is PendingAction.JoinRoom -> { log(LogLevel.INFO, "Executing pending join room", "${action.roomCode} as ${action.username}") sendMessage(MessageTypes.JOIN_ROOM, JoinRoomPayload(action.roomCode.uppercase(), action.username)) } } } /** * Disconnect from the server */ fun disconnect() { log(LogLevel.INFO, "Disconnecting from server") releaseWakeLock() // Release wake lock when disconnecting pingJob?.cancel() pingJob = null webSocket?.close(1000, "User disconnected") webSocket = null _connectionState.value = ConnectionState.DISCONNECTED // Clear session and state on explicit disconnect sessionToken = null storedRoomCode = null storedUsername = null pendingAction = null _roomState.value = null _role.value = RoomRole.NONE _userId.value = null _pendingJoinRequests.value = emptyList() _bufferingUsers.value = emptyList() // Clear from persistent storage clearPersistedSession() reconnectAttempts = 0 scope.launch { _events.emit(ListenTogetherEvent.Disconnected) } } private fun startPingJob() { pingJob?.cancel() pingJob = scope.launch { while (true) { delay(PING_INTERVAL_MS) // Refresh the WakeLock on every ping cycle so it never expires while the // connection is active. Without this, the 10-minute timeout can lapse during // long sessions with the screen off, allowing the CPU to throttle and // causing the WebSocket to degrade, resulting in choppy audio. acquireWakeLock() sendMessageNoPayload(MessageTypes.PING) } } } @Suppress("DEPRECATION") private fun acquireWakeLock() { if (wakeLock == null) { val powerManager = context.getSystemService() wakeLock = powerManager?.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, "Metrolist:ListenTogether", ) } // Always release before acquiring so that the timeout is reset on each call. // This is safe because the ping job calls acquireWakeLock() every PING_INTERVAL_MS // (25 s), ensuring the lock is refreshed well before the 10-minute window elapses. // Without the release-and-reacquire pattern the first acquire() sets the countdown // and subsequent calls while isHeld is true are no-ops, so the lock would still // expire after 10 minutes of continuous screen-off sessions. if (wakeLock?.isHeld == true) { wakeLock?.release() } wakeLock?.acquire(10 * 60 * 1000L) log(LogLevel.DEBUG, "Wake lock acquired") } private fun releaseWakeLock() { if (wakeLock?.isHeld == true) { wakeLock?.release() log(LogLevel.DEBUG, "Wake lock released") } } private fun ensureNotificationChannel() { try { val nm = context.getSystemService(NotificationManager::class.java) val existing = nm?.getNotificationChannel(NOTIFICATION_CHANNEL_ID) if (existing == null) { val channel = NotificationChannel( NOTIFICATION_CHANNEL_ID, context.getString(R.string.listen_together_notification_channel_name), NotificationManager.IMPORTANCE_HIGH, ) channel.description = context.getString(R.string.listen_together_notification_channel_desc) nm?.createNotificationChannel(channel) } } catch (e: Exception) { log(LogLevel.WARNING, "Failed to create notification channel", e.message) } } @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) private fun showJoinRequestNotification(payload: JoinRequestPayload) { val notifId = (System.currentTimeMillis() % Int.MAX_VALUE).toInt() // Store notification ID for this user so we can dismiss it from UI actions joinRequestNotifications[payload.userId] = notifId val approveIntent = Intent(context, ListenTogetherActionReceiver::class.java).apply { action = ACTION_APPROVE_JOIN putExtra(EXTRA_USER_ID, payload.userId) putExtra(EXTRA_NOTIFICATION_ID, notifId) } val rejectIntent = Intent(context, ListenTogetherActionReceiver::class.java).apply { action = ACTION_REJECT_JOIN putExtra(EXTRA_USER_ID, payload.userId) putExtra(EXTRA_NOTIFICATION_ID, notifId) } val approvePI = PendingIntent.getBroadcast( context, payload.userId.hashCode(), approveIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val rejectPI = PendingIntent.getBroadcast( context, payload.userId.hashCode().inv(), rejectIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val content = context.getString(R.string.listen_together_join_request_notification, payload.username) val builder = NotificationCompat .Builder(context, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.share) .setContentTitle(context.getString(R.string.listen_together)) .setContentText(content) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) .addAction(0, context.getString(R.string.approve), approvePI) .addAction(0, context.getString(R.string.reject), rejectPI) if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { NotificationManagerCompat.from(context).notify(notifId, builder.build()) } } @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) private fun showSuggestionNotification(payload: SuggestionReceivedPayload) { val notifId = (System.currentTimeMillis() % Int.MAX_VALUE).toInt() // Store notification ID for this suggestion so we can dismiss it from UI actions suggestionNotifications[payload.suggestionId] = notifId val approveIntent = Intent(context, ListenTogetherActionReceiver::class.java).apply { action = ACTION_APPROVE_SUGGESTION putExtra(EXTRA_SUGGESTION_ID, payload.suggestionId) putExtra(EXTRA_NOTIFICATION_ID, notifId) } val rejectIntent = Intent(context, ListenTogetherActionReceiver::class.java).apply { action = ACTION_REJECT_SUGGESTION putExtra(EXTRA_SUGGESTION_ID, payload.suggestionId) putExtra(EXTRA_NOTIFICATION_ID, notifId) } val approvePI = PendingIntent.getBroadcast( context, payload.suggestionId.hashCode(), approveIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val rejectPI = PendingIntent.getBroadcast( context, payload.suggestionId.hashCode().inv(), rejectIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val content = context.getString(R.string.listen_together_suggestion_received, payload.fromUsername, payload.trackInfo.title) val builder = NotificationCompat .Builder(context, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.share) .setContentTitle(context.getString(R.string.listen_together)) .setContentText(content) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) .addAction(0, context.getString(R.string.approve), approvePI) .addAction(0, context.getString(R.string.reject), rejectPI) if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { NotificationManagerCompat.from(context).notify(notifId, builder.build()) } } private fun handleDisconnect() { pingJob?.cancel() pingJob = null // Don't clear room state - we might reconnect // Only update connection state _connectionState.value = ConnectionState.DISCONNECTED _pendingJoinRequests.value = emptyList() _bufferingUsers.value = emptyList() // If we have a session, try to reconnect if (sessionToken != null && _roomState.value != null) { log(LogLevel.INFO, "Connection lost, will attempt to reconnect") handleConnectionFailure(Exception("Connection lost")) } else { scope.launch { _events.emit(ListenTogetherEvent.Disconnected) } } } private fun handleConnectionFailure(t: Throwable) { pingJob?.cancel() pingJob = null // Always try to reconnect if we have a session token or pending action val shouldReconnect = sessionToken != null || _roomState.value != null || pendingAction != null if (!isNetworkAvailable) { log(LogLevel.WARNING, "Connection failure, waiting for network", t.message) _connectionState.value = ConnectionState.DISCONNECTED return } if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS && shouldReconnect) { reconnectAttempts++ _connectionState.value = ConnectionState.RECONNECTING val delayMs = calculateBackoffDelay(reconnectAttempts) val delaySeconds = delayMs / 1000 log( LogLevel.INFO, "Attempting reconnect", "Attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS, waiting ${delaySeconds}s, reason: ${t.message}", ) scope.launch { _events.emit(ListenTogetherEvent.Reconnecting(reconnectAttempts, MAX_RECONNECT_ATTEMPTS)) delay(delayMs) // Check if we're still supposed to be reconnecting if (_connectionState.value == ConnectionState.RECONNECTING || _connectionState.value == ConnectionState.DISCONNECTED) { log(LogLevel.INFO, "Reconnecting after backoff", "Delay was ${delaySeconds}s") connect() } } } else { _connectionState.value = ConnectionState.ERROR // If we had a session, notify user but keep session data for manual retry if (sessionToken != null) { log( LogLevel.ERROR, "Reconnection failed", "Max attempts reached, but session preserved for manual reconnect", ) scope.launch { _events.emit( ListenTogetherEvent.ConnectionError( "Connection failed after $MAX_RECONNECT_ATTEMPTS attempts. ${t.message ?: "Unknown error"}", ), ) } } else { // No session, so clear everything sessionToken = null storedRoomCode = null storedUsername = null _roomState.value = null _role.value = RoomRole.NONE clearPersistedSession() scope.launch { _events.emit(ListenTogetherEvent.ConnectionError(t.message ?: "Unknown error")) } } } } private fun handleMessage(data: ByteArray) { log(LogLevel.DEBUG, "Received message", "${data.size} bytes") try { // Decode message using Protobuf val (msgType, payloadBytes) = codec.decode(data) when (msgType) { MessageTypes.ROOM_CREATED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? RoomCreatedPayload ?: return _userId.value = payload.userId _role.value = RoomRole.HOST sessionToken = payload.sessionToken storedRoomCode = payload.roomCode wasHost = true sessionStartTime = System.currentTimeMillis() _roomState.value = RoomState( roomCode = payload.roomCode, hostId = payload.userId, users = listOf(UserInfo(payload.userId, storedUsername ?: "", true)), isPlaying = false, position = 0, lastUpdate = System.currentTimeMillis(), volume = 1f, ) // Save session to persistent storage savePersistedSession() acquireWakeLock() // Keep connection alive while in room log(LogLevel.INFO, "Room created", "Code: ${payload.roomCode}") scope.launch { _events.emit(ListenTogetherEvent.RoomCreated(payload.roomCode, payload.userId)) } // Global toast for room creation so the host sees it regardless of UI scope.launch(Dispatchers.Main) { Toast .makeText( context, context.getString(R.string.listen_together_room_created, payload.roomCode), Toast.LENGTH_LONG, ).show() } } MessageTypes.JOIN_REQUEST -> { val payload = codec.decodePayload(msgType, payloadBytes) as? JoinRequestPayload ?: return // Check if user is blocked if (isUserBlocked(payload.username)) { log(LogLevel.INFO, "Join request from blocked user ignored", "User: ${payload.username}") // Silently reject blocked users rejectJoin(payload.userId, "You are blocked") return } _pendingJoinRequests.value += payload log(LogLevel.INFO, "Join request received", "User: ${payload.username}") // Check if auto-approval is enabled val autoApprovalEnabled = context.dataStore.get(ListenTogetherAutoApprovalKey, false) if (_role.value == RoomRole.HOST) { if (autoApprovalEnabled) { // Automatically approve the join request log(LogLevel.INFO, "Auto-approving join request", "User: ${payload.username}") approveJoin(payload.userId) } else { // Notify host with Approve/Reject actions if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ) { showJoinRequestNotification(payload) } } } scope.launch { _events.emit(ListenTogetherEvent.JoinRequestReceived(payload.userId, payload.username)) } } MessageTypes.JOIN_APPROVED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? JoinApprovedPayload ?: return _userId.value = payload.userId _role.value = RoomRole.GUEST sessionToken = payload.sessionToken storedRoomCode = payload.roomCode wasHost = false sessionStartTime = System.currentTimeMillis() _roomState.value = payload.state // Save session to persistent storage savePersistedSession() acquireWakeLock() // Keep connection alive while in room log(LogLevel.INFO, "Joined room", "Code: ${payload.roomCode}") scope.launch { _events.emit(ListenTogetherEvent.JoinApproved(payload.roomCode, payload.userId, payload.state)) } } MessageTypes.JOIN_REJECTED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? JoinRejectedPayload ?: return log(LogLevel.WARNING, "Join rejected", payload.reason) scope.launch { _events.emit(ListenTogetherEvent.JoinRejected(payload.reason)) } } MessageTypes.USER_JOINED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? UserJoinedPayload ?: return _roomState.value = _roomState.value?.copy( users = _roomState.value!!.users + UserInfo(payload.userId, payload.username, false), ) _pendingJoinRequests.value = _pendingJoinRequests.value.filter { it.userId != payload.userId } // Dismiss notification if it exists joinRequestNotifications.remove(payload.userId)?.let { notifId -> NotificationManagerCompat.from(context).cancel(notifId) } log(LogLevel.INFO, "User joined", payload.username) scope.launch { _events.emit(ListenTogetherEvent.UserJoined(payload.userId, payload.username)) } } MessageTypes.USER_LEFT -> { val payload = codec.decodePayload(msgType, payloadBytes) as? UserLeftPayload ?: return _roomState.value = _roomState.value?.copy( users = _roomState.value!!.users.filter { it.userId != payload.userId }, ) log(LogLevel.INFO, "User left", payload.username) scope.launch { _events.emit(ListenTogetherEvent.UserLeft(payload.userId, payload.username)) } } MessageTypes.HOST_CHANGED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? HostChangedPayload ?: return _roomState.value = _roomState.value?.copy( hostId = payload.newHostId, users = _roomState.value!!.users.map { it.copy(isHost = it.userId == payload.newHostId) }, ) if (payload.newHostId == _userId.value) { _role.value = RoomRole.HOST } else if (_role.value == RoomRole.HOST) { // Lost host role _role.value = RoomRole.GUEST } log(LogLevel.INFO, "Host changed", "New host: ${payload.newHostName}") scope.launch { _events.emit(ListenTogetherEvent.HostChanged(payload.newHostId, payload.newHostName)) } } MessageTypes.KICKED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? KickedPayload ?: return log(LogLevel.WARNING, "Kicked from room", payload.reason) releaseWakeLock() // Release wake lock when kicked sessionToken = null _roomState.value = null _role.value = RoomRole.NONE scope.launch { _events.emit(ListenTogetherEvent.Kicked(payload.reason)) } } MessageTypes.SYNC_PLAYBACK -> { val payload = codec.decodePayload(msgType, payloadBytes) as? PlaybackActionPayload ?: return log(LogLevel.DEBUG, "Playback sync", "Action: ${payload.action}") // Update room state based on action when (payload.action) { PlaybackActions.PLAY -> { _roomState.value = _roomState.value?.copy( isPlaying = true, position = payload.position ?: _roomState.value!!.position, ) } PlaybackActions.PAUSE -> { _roomState.value = _roomState.value?.copy( isPlaying = false, position = payload.position ?: _roomState.value!!.position, ) } PlaybackActions.SEEK -> { _roomState.value = _roomState.value?.copy( position = payload.position ?: _roomState.value!!.position, ) } PlaybackActions.CHANGE_TRACK -> { _roomState.value = _roomState.value?.copy( currentTrack = payload.trackInfo, isPlaying = false, position = 0, ) } PlaybackActions.QUEUE_ADD -> { val ti = payload.trackInfo if (ti != null) { val currentQueue = _roomState.value?.queue ?: emptyList() _roomState.value = _roomState.value?.copy( queue = if (payload.insertNext == true) listOf(ti) + currentQueue else currentQueue + ti, ) } } PlaybackActions.QUEUE_REMOVE -> { val id = payload.trackId if (!id.isNullOrEmpty()) { val currentQueue = _roomState.value?.queue ?: emptyList() _roomState.value = _roomState.value?.copy( queue = currentQueue.filter { it.id != id }, ) } } PlaybackActions.QUEUE_CLEAR -> { _roomState.value = _roomState.value?.copy(queue = emptyList()) } PlaybackActions.SET_VOLUME -> { val vol = payload.volume if (vol != null) { _roomState.value = _roomState.value?.copy(volume = vol.coerceIn(0f, 1f)) } } } scope.launch { _events.emit(ListenTogetherEvent.PlaybackSync(payload)) } } MessageTypes.BUFFER_WAIT -> { val payload = codec.decodePayload(msgType, payloadBytes) as? BufferWaitPayload ?: return _bufferingUsers.value = payload.waitingFor log(LogLevel.DEBUG, "Waiting for buffering", "Users: ${payload.waitingFor.size}") scope.launch { _events.emit(ListenTogetherEvent.BufferWait(payload.trackId, payload.waitingFor)) } } MessageTypes.BUFFER_COMPLETE -> { val payload = codec.decodePayload(msgType, payloadBytes) as? BufferCompletePayload ?: return _bufferingUsers.value = emptyList() log(LogLevel.INFO, "All users buffered", "Track: ${payload.trackId}") scope.launch { _events.emit(ListenTogetherEvent.BufferComplete(payload.trackId)) } } MessageTypes.SYNC_STATE -> { val payload = codec.decodePayload(msgType, payloadBytes) as? SyncStatePayload ?: return log(LogLevel.INFO, "Sync state received", "Playing: ${payload.isPlaying}, Position: ${payload.position}") scope.launch { _events.emit(ListenTogetherEvent.SyncStateReceived(payload)) } } MessageTypes.SUGGESTION_RECEIVED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? SuggestionReceivedPayload ?: return // Only host should receive suggestions if (_role.value == RoomRole.HOST) { // Check if user is blocked if (isUserBlocked(payload.fromUsername)) { log(LogLevel.INFO, "Suggestion from blocked user ignored", "User: ${payload.fromUsername}") return } log(LogLevel.INFO, "Suggestion received", "${payload.fromUsername}: ${payload.trackInfo.title}") // Check if auto-approval of suggestions is enabled val autoApproveSuggestionsEnabled = context.dataStore.get(ListenTogetherAutoApproveSuggestionsKey, false) if (autoApproveSuggestionsEnabled) { // Automatically approve the suggestion log(LogLevel.INFO, "Auto-approving suggestion", "${payload.fromUsername}: ${payload.trackInfo.title}") approveSuggestion(payload.suggestionId) } else { // Add to pending list and show notification _pendingSuggestions.value += payload // Notify the host with actionable notification if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ) { showSuggestionNotification(payload) } } } } MessageTypes.SUGGESTION_APPROVED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? SuggestionApprovedPayload ?: return log(LogLevel.INFO, "Suggestion approved", payload.trackInfo.title) // Dismiss notification if it exists (for host who approved via another device/modal) suggestionNotifications.remove(payload.suggestionId)?.let { notifId -> NotificationManagerCompat.from(context).cancel(notifId) } // For guests, optionally notify via events; UI can react if needed } MessageTypes.SUGGESTION_REJECTED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? SuggestionRejectedPayload ?: return log(LogLevel.WARNING, "Suggestion rejected", payload.reason ?: "") // Dismiss notification if it exists suggestionNotifications.remove(payload.suggestionId)?.let { notifId -> NotificationManagerCompat.from(context).cancel(notifId) } // For guests, optionally notify via events } MessageTypes.ERROR -> { val payload = codec.decodePayload(msgType, payloadBytes) as? ErrorPayload ?: return log(LogLevel.ERROR, "Server error", "${payload.code}: ${payload.message}") // Handle specific error cases when (payload.code) { "session_not_found" -> { // Session expired on server, try to rejoin the room if (storedRoomCode != null && storedUsername != null && !wasHost) { log( LogLevel.WARNING, "Session expired on server", "Attempting automatic rejoin to room: $storedRoomCode", ) // Try rejoining as a guest scope.launch { delay(500) // Small delay before rejoin attempt joinRoom(storedRoomCode!!, storedUsername!!) } } else if (storedRoomCode != null && storedUsername != null) { // Host session expired - would need to create new room log( LogLevel.WARNING, "Host session expired", "Room: $storedRoomCode - manual intervention may be needed", ) clearPersistedSession() sessionToken = null } else { clearPersistedSession() sessionToken = null } } else -> {} } scope.launch { _events.emit(ListenTogetherEvent.ServerError(payload.code, payload.message)) } } MessageTypes.PONG -> { log(LogLevel.DEBUG, "Pong received") } MessageTypes.RECONNECTED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? ReconnectedPayload ?: return _userId.value = payload.userId _role.value = if (payload.isHost) RoomRole.HOST else RoomRole.GUEST _roomState.value = payload.state // Update persisted session info wasHost = payload.isHost sessionStartTime = System.currentTimeMillis() savePersistedSession() // Reset reconnection attempts on successful reconnection reconnectAttempts = 0 acquireWakeLock() // Re-acquire wake lock after reconnection log( LogLevel.INFO, "Successfully reconnected to room", "Code: ${payload.roomCode}, isHost: ${payload.isHost}, attempt was $reconnectAttempts", ) scope.launch { _events.emit( ListenTogetherEvent.Reconnected(payload.roomCode, payload.userId, payload.state, payload.isHost), ) } } MessageTypes.USER_RECONNECTED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? UserReconnectedPayload ?: return // Mark user as connected in the room state _roomState.value = _roomState.value?.copy( users = _roomState.value!!.users.map { user -> if (user.userId == payload.userId) user.copy(isConnected = true) else user }, ) log(LogLevel.INFO, "User reconnected", payload.username) scope.launch { _events.emit(ListenTogetherEvent.UserReconnected(payload.userId, payload.username)) } } MessageTypes.USER_DISCONNECTED -> { val payload = codec.decodePayload(msgType, payloadBytes) as? UserDisconnectedPayload ?: return // Mark user as disconnected in the room state _roomState.value = _roomState.value?.copy( users = _roomState.value!!.users.map { user -> if (user.userId == payload.userId) user.copy(isConnected = false) else user }, ) log(LogLevel.INFO, "User temporarily disconnected", payload.username) scope.launch { _events.emit(ListenTogetherEvent.UserDisconnected(payload.userId, payload.username)) } } else -> { log(LogLevel.WARNING, "Unknown message type", msgType) } } } catch (e: Exception) { log(LogLevel.ERROR, "Error parsing message", e.message) } } private inline fun sendMessage( type: String, payload: T?, ) { try { val data = codec.encode(type, payload) log(LogLevel.DEBUG, "Sending message", "$type (protobuf)") val success = webSocket?.send(okio.ByteString.of(*data)) ?: false if (!success) { log(LogLevel.ERROR, "Failed to send message", type) } } catch (e: Exception) { log(LogLevel.ERROR, "Error encoding message", "$type: ${e.message}") } } private fun sendMessageNoPayload(type: String) { sendMessage(type, null) } // Public API methods /** * Create a new listening room. * If not connected, will queue the action and connect first. */ fun createRoom(username: String) { // Clear any existing session to ensure we create a new room instead of reconnecting clearPersistedSession() sessionToken = null storedRoomCode = null wasHost = false storedUsername = username if (_connectionState.value == ConnectionState.CONNECTED) { sendMessage(MessageTypes.CREATE_ROOM, CreateRoomPayload(username)) } else { log(LogLevel.INFO, "Not connected, queueing create room action") pendingAction = PendingAction.CreateRoom(username) if (_connectionState.value == ConnectionState.DISCONNECTED || _connectionState.value == ConnectionState.ERROR ) { connect() } // If CONNECTING or RECONNECTING, the action will be executed when connected } } /** * Join an existing room. * If not connected, will queue the action and connect first. */ fun joinRoom( roomCode: String, username: String, ) { // Clear any existing session to ensure we join the new room instead of reconnecting clearPersistedSession() sessionToken = null storedRoomCode = null wasHost = false storedUsername = username if (_connectionState.value == ConnectionState.CONNECTED) { sendMessage(MessageTypes.JOIN_ROOM, JoinRoomPayload(roomCode.uppercase(), username)) } else { log(LogLevel.INFO, "Not connected, queueing join room action") pendingAction = PendingAction.JoinRoom(roomCode, username) if (_connectionState.value == ConnectionState.DISCONNECTED || _connectionState.value == ConnectionState.ERROR ) { connect() } // If CONNECTING or RECONNECTING, the action will be executed when connected } } /** * Leave the current room */ fun leaveRoom() { sendMessageNoPayload(MessageTypes.LEAVE_ROOM) // Clear session info on intentional leave sessionToken = null storedRoomCode = null storedUsername = null pendingAction = null _roomState.value = null _role.value = RoomRole.NONE _userId.value = null _pendingJoinRequests.value = emptyList() _bufferingUsers.value = emptyList() // Clear from persistent storage clearPersistedSession() releaseWakeLock() } /** * Approve a join request (host only) */ fun approveJoin(userId: String) { if (_role.value != RoomRole.HOST) { log(LogLevel.ERROR, "Cannot approve join", "Not host") return } sendMessage(MessageTypes.APPROVE_JOIN, ApproveJoinPayload(userId)) // Dismiss notification immediately when approved from UI joinRequestNotifications.remove(userId)?.let { notifId -> NotificationManagerCompat.from(context).cancel(notifId) } } /** * Reject a join request (host only) */ fun rejectJoin( userId: String, reason: String? = null, ) { if (_role.value != RoomRole.HOST) { log(LogLevel.ERROR, "Cannot reject join", "Not host") return } sendMessage(MessageTypes.REJECT_JOIN, RejectJoinPayload(userId, reason)) _pendingJoinRequests.value = _pendingJoinRequests.value.filter { it.userId != userId } // Dismiss notification immediately when rejected from UI joinRequestNotifications.remove(userId)?.let { notifId -> NotificationManagerCompat.from(context).cancel(notifId) } } /** * Kick a user from the room (host only) */ fun kickUser( userId: String, reason: String? = null, ) { if (_role.value != RoomRole.HOST) { log(LogLevel.ERROR, "Cannot kick user", "Not host") return } sendMessage(MessageTypes.KICK_USER, KickUserPayload(userId, reason)) } /** * Transfer host role to another user (host only) */ fun transferHost(newHostId: String) { if (_role.value != RoomRole.HOST) { log(LogLevel.ERROR, "Cannot transfer host", "Not host") return } sendMessage(MessageTypes.TRANSFER_HOST, TransferHostPayload(newHostId)) } /** * Send a playback action (host only) */ fun sendPlaybackAction( action: String, trackId: String? = null, position: Long? = null, trackInfo: TrackInfo? = null, insertNext: Boolean? = null, queue: List? = null, queueTitle: String? = null, volume: Float? = null, ) { if (_role.value != RoomRole.HOST) { log(LogLevel.ERROR, "Cannot control playback", "Not host") return } sendMessage( MessageTypes.PLAYBACK_ACTION, PlaybackActionPayload(action, trackId, position, trackInfo, insertNext, queue, queueTitle, volume), ) } /** * Signal that buffering is complete for the current track */ fun sendBufferReady(trackId: String) { sendMessage(MessageTypes.BUFFER_READY, BufferReadyPayload(trackId)) } /** * Suggest a track to the host (guest only) */ fun suggestTrack(trackInfo: TrackInfo) { if (!isInRoom) { log(LogLevel.ERROR, "Cannot suggest track", "Not in room") return } if (_role.value == RoomRole.HOST) { log(LogLevel.WARNING, "Host should not suggest tracks") return } sendMessage(MessageTypes.SUGGEST_TRACK, SuggestTrackPayload(trackInfo)) scope.launch(Dispatchers.Main) { Toast.makeText(context, context.getString(R.string.listen_together_suggestion_sent), Toast.LENGTH_SHORT).show() } } /** * Approve a suggestion (host only) */ fun approveSuggestion(suggestionId: String) { if (_role.value != RoomRole.HOST) { log(LogLevel.ERROR, "Cannot approve suggestion", "Not host") return } sendMessage(MessageTypes.APPROVE_SUGGESTION, ApproveSuggestionPayload(suggestionId)) // Remove locally from pending list _pendingSuggestions.value = _pendingSuggestions.value.filter { it.suggestionId != suggestionId } // Dismiss notification immediately when approved from UI suggestionNotifications.remove(suggestionId)?.let { notifId -> NotificationManagerCompat.from(context).cancel(notifId) } } /** * Reject a suggestion (host only) */ fun rejectSuggestion( suggestionId: String, reason: String? = null, ) { if (_role.value != RoomRole.HOST) { log(LogLevel.ERROR, "Cannot reject suggestion", "Not host") return } sendMessage(MessageTypes.REJECT_SUGGESTION, RejectSuggestionPayload(suggestionId, reason)) _pendingSuggestions.value = _pendingSuggestions.value.filter { it.suggestionId != suggestionId } // Dismiss notification immediately when rejected from UI suggestionNotifications.remove(suggestionId)?.let { notifId -> NotificationManagerCompat.from(context).cancel(notifId) } } /** * Request current playback state from server (for guest re-sync) */ fun requestSync() { if (_roomState.value == null) { log(LogLevel.ERROR, "Cannot request sync", "Not in room") return } log(LogLevel.INFO, "Requesting sync state from server") sendMessageNoPayload(MessageTypes.REQUEST_SYNC) } /** * Block a user permanently (internal list). Prevents their join requests and suggestions from appearing. */ fun blockUser(username: String) { val updated = _blockedUsernames.value.toMutableSet() updated.add(username) _blockedUsernames.value = updated // Filter out blocked users from pending requests and suggestions _pendingJoinRequests.value = _pendingJoinRequests.value .filter { it.username !in _blockedUsernames.value } _pendingSuggestions.value = _pendingSuggestions.value .filter { it.fromUsername !in _blockedUsernames.value } // Save to storage scope.launch { saveBlockedUsernames() } log(LogLevel.INFO, "User blocked", username) } /** * Unblock a previously blocked user */ fun unblockUser(username: String) { val updated = _blockedUsernames.value.toMutableSet() updated.remove(username) _blockedUsernames.value = updated // Save to storage scope.launch { saveBlockedUsernames() } log(LogLevel.INFO, "User unblocked", username) } /** * Check if a user is blocked */ fun isUserBlocked(username: String): Boolean = username in _blockedUsernames.value /** * Check if currently in a room */ val isInRoom: Boolean get() = _roomState.value != null /** * Check if current user is host */ val isHost: Boolean get() = _role.value == RoomRole.HOST /** * Force reconnection to server (useful for manual recovery) */ fun forceReconnect() { log(LogLevel.INFO, "Forcing reconnection to server") reconnectAttempts = 0 // Reset attempts to retry from start if (webSocket != null) { try { webSocket?.close(1000, "Forcing reconnection") } catch (e: Exception) { log(LogLevel.DEBUG, "Error closing WebSocket", e.message) } webSocket = null } _connectionState.value = ConnectionState.DISCONNECTED // Attempt connection with reset backoff scope.launch { delay(500) connect() } } /** * Check if there's a persisted session available for recovery */ val hasPersistedSession: Boolean get() = sessionToken != null && storedRoomCode != null /** * Get the persisted room code if available */ fun getPersistedRoomCode(): String? = storedRoomCode /** * Get current session age in milliseconds */ fun getSessionAge(): Long = if (sessionStartTime > 0) { System.currentTimeMillis() - sessionStartTime } else { 0L } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/listentogether/ListenTogetherManager.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.listentogether import android.content.Context import androidx.media3.common.MediaItem import androidx.media3.common.Player import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.constants.ListenTogetherSyncVolumeKey import com.metrolist.music.extensions.currentMetadata import com.metrolist.music.extensions.metadata import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.MediaMetadata import com.metrolist.music.models.MediaMetadata.Album import com.metrolist.music.models.MediaMetadata.Artist import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.PlayerConnection import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.utils.dataStore import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton /** * Manager that bridges the Listen Together WebSocket client with the music player. * Handles syncing playback actions between connected users. */ @Singleton class ListenTogetherManager @Inject constructor( private val client: ListenTogetherClient, @ApplicationContext private val context: Context ) { companion object { private const val TAG = "ListenTogetherManager" // Debounce threshold for playback syncs - prevents excessive seeking/pausing // Increased from 200ms to 1000ms to reduce choppy audio for guests private const val SYNC_DEBOUNCE_THRESHOLD_MS = 1000L // Position tolerance - only seek if difference exceeds this (prevents micro-adjustments) // Increased from 500ms to 2000ms to reduce unnecessary seeks that interrupt playback private const val POSITION_TOLERANCE_MS = 2000L // Large position tolerance - only seek during playback if difference exceeds this // This prevents interrupting active playback for small drifts private const val PLAYBACK_POSITION_TOLERANCE_MS = 3000L } private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) init { observePreferences() } private var playerConnection: PlayerConnection? = null private var eventCollectorJob: Job? = null private var queueObserverJob: Job? = null private var volumeObserverJob: Job? = null private var playerListenerRegistered = false private val syncHostVolumeEnabled = MutableStateFlow(true) private var lastSyncedVolume: Float? = null private var previousMuteState: Boolean? = null private var muteForcedByPreference = false private var lastRole: RoomRole = RoomRole.NONE // Whether we're currently syncing (to prevent feedback loops) @Volatile private var isSyncing = false // Track the last state we synced to avoid duplicate events private var lastSyncedIsPlaying: Boolean? = null private var lastSyncedTrackId: String? = null // Track last sync action time for debouncing (prevents excessive seeking/pausing) private var lastSyncActionTime: Long = 0L // Track ID being buffered private var bufferingTrackId: String? = null // Track active sync job to cancel it if a better update arrives private var activeSyncJob: Job? = null // Generation ID for track changes - incremented on each new track change // Used to prevent old coroutines from overwriting newer track loads private var currentTrackGeneration: Int = 0 // Pending sync to apply after buffering completes for guest private var pendingSyncState: SyncStatePayload? = null // Track if a buffer-complete arrived before the pending sync was ready private var bufferCompleteReceivedForTrack: String? = null // Expose client state val connectionState = client.connectionState val roomState = client.roomState val role = client.role val userId = client.userId val pendingJoinRequests = client.pendingJoinRequests val bufferingUsers = client.bufferingUsers val logs = client.logs val events = client.events val blockedUsernames = client.blockedUsernames val pendingSuggestions = client.pendingSuggestions val isInRoom: Boolean get() = client.isInRoom val isHost: Boolean get() = client.isHost val hasPersistedSession: Boolean get() = client.hasPersistedSession private val playerListener = object : Player.Listener { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { try { if (isSyncing || !isHost || !isInRoom) return val connection = playerConnection ?: return val player = connection.player Timber.tag(TAG).d("Play state changed: $playWhenReady (reason: $reason)") // ALWAYS ensure track is synced before play/pause val currentTrackId = player.currentMediaItem?.mediaId if (currentTrackId != null && currentTrackId != lastSyncedTrackId) { Timber.tag(TAG) .d("[SYNC] Sending track change before play state: track = $currentTrackId") player.currentMetadata?.let { metadata -> sendTrackChangeInternal(metadata) lastSyncedTrackId = currentTrackId // Reset play state since server resets IsPlaying on track change lastSyncedIsPlaying = false } // ALWAYS send play state after track change if host is playing // Server sets IsPlaying=false on track change, so we must send it if (playWhenReady) { Timber.tag(TAG).d("[SYNC] Host is playing, sending PLAY after track change") lastSyncedIsPlaying = true val position = player.currentPosition client.sendPlaybackAction(PlaybackActions.PLAY, position = position) } return } // Only send play/pause if track is already synced sendPlayState(playWhenReady, player) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in onPlayWhenReadyChanged") } } private fun sendPlayState(playWhenReady: Boolean, player: Player) { try { val position = player.currentPosition if (playWhenReady) { Timber.tag(TAG).d("Host sending PLAY at position $position") client.sendPlaybackAction(PlaybackActions.PLAY, position = position) lastSyncedIsPlaying = true } else if (!playWhenReady && (lastSyncedIsPlaying == true)) { Timber.tag(TAG).d("Host sending PAUSE at position $position") client.sendPlaybackAction(PlaybackActions.PAUSE, position = position) lastSyncedIsPlaying = false } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in sendPlayState") } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { try { if (isSyncing || !isHost || !isInRoom) return if (mediaItem == null) return val connection = playerConnection ?: return val player = connection.player val trackId = mediaItem.mediaId if (trackId == lastSyncedTrackId) return lastSyncedTrackId = trackId // Reset play state tracking since server resets IsPlaying on track change lastSyncedIsPlaying = false // Get metadata and send track change player.currentMetadata?.let { metadata -> Timber.tag(TAG).d("Host sending track change: ${metadata.title}") sendTrackChange(metadata) // ALWAYS send PLAY after track change if host is currently playing // Server sets IsPlaying=false on track change, so we must re-send it val isPlaying = player.playWhenReady if (isPlaying) { Timber.tag(TAG).d("Host is playing during track change, sending PLAY") lastSyncedIsPlaying = true val position = player.currentPosition client.sendPlaybackAction(PlaybackActions.PLAY, position = position) } } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in onMediaItemTransition") } } override fun onPositionDiscontinuity( oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int ) { try { if (isSyncing || !isHost || !isInRoom) return // Only send seek if it was a user-initiated seek if (reason == Player.DISCONTINUITY_REASON_SEEK) { Timber.tag(TAG).d("Host sending SEEK to ${newPosition.positionMs}") client.sendPlaybackAction(PlaybackActions.SEEK, position = newPosition.positionMs) } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in onPositionDiscontinuity") } } } /** * Set the player connection for playback sync. * Should be called when PlayerConnection is available. */ fun setPlayerConnection(connection: PlayerConnection?) { Timber.tag(TAG).d("setPlayerConnection: ${connection != null}, isInRoom: $isInRoom") try { // Remove old listener and callback safely val oldConnection = playerConnection if (playerListenerRegistered && oldConnection != null) { try { oldConnection.player.removeListener(playerListener) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error removing old player listener") } playerListenerRegistered = false } oldConnection?.shouldBlockPlaybackChanges = null oldConnection?.onSkipPrevious = null oldConnection?.onSkipNext = null oldConnection?.onRestartSong = null playerConnection = connection // Set up playback blocking for guests connection?.shouldBlockPlaybackChanges = { // Block if we're in a room as a guest (not host) isInRoom && !isHost } // Add listener if in room if (connection != null && isInRoom) { try { connection.player.addListener(playerListener) playerListenerRegistered = true Timber.tag(TAG).d("Added player listener for room sync") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to add player listener") playerListenerRegistered = false } // Hook up skip actions connection.onSkipPrevious = { try { if (isHost && !isSyncing) { Timber.tag(TAG).d("Host Skip Previous triggered") client.sendPlaybackAction(PlaybackActions.SKIP_PREV) } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in onSkipPrevious") } } connection.onSkipNext = { try { if (isHost && !isSyncing) { Timber.tag(TAG).d("Host Skip Next triggered") client.sendPlaybackAction(PlaybackActions.SKIP_NEXT) } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in onSkipNext") } } // Hook up restart action connection.onRestartSong = { try { if (isHost && !isSyncing) { Timber.tag(TAG).d("Host Restart Song triggered (sending 1ms as 0ms workaround)") client.sendPlaybackAction(PlaybackActions.SEEK, position = 1L) } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in onRestartSong") } } } // Start/stop queue observation based on role if (connection != null && isInRoom && isHost) { startQueueSyncObservation() startHeartbeat() startVolumeSyncObservation() } else { stopQueueSyncObservation() stopHeartbeat() stopVolumeSyncObservation() } updateGuestMuteState() } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in setPlayerConnection") } } private fun observePreferences() { scope.launch { context.dataStore.data .map { it[ListenTogetherSyncVolumeKey] ?: true } .distinctUntilChanged() .collect { enabled -> syncHostVolumeEnabled.value = enabled } } } /** * Initialize event collection. Should be called once at app start. */ fun initialize() { Timber.tag(TAG).d("Initializing ListenTogetherManager") eventCollectorJob?.cancel() eventCollectorJob = scope.launch { client.events.collect { event -> try { Timber.tag(TAG).d("Received event: $event") handleEvent(event) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error handling event: $event") } } } // Role change listener scope.launch { role.collect { newRole -> try { val previousRole = lastRole lastRole = newRole val wasHost = previousRole == RoomRole.HOST if (newRole == RoomRole.HOST && !wasHost) { val connection = playerConnection if (connection != null) { Timber.tag(TAG).d("Role changed to HOST, starting sync services") startQueueSyncObservation() startHeartbeat() startVolumeSyncObservation() // Re-register listener if needed if (!playerListenerRegistered) { try { connection.player.addListener(playerListener) playerListenerRegistered = true } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to add player listener on role change") } } } } else if (newRole != RoomRole.HOST && wasHost) { Timber.tag(TAG).d("Role changed from HOST, stopping sync services") stopQueueSyncObservation() stopHeartbeat() stopVolumeSyncObservation() } updateGuestMuteState() } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in role change handler") } } } } private fun handleEvent(event: ListenTogetherEvent) { when (event) { is ListenTogetherEvent.Connected -> { Timber.tag(TAG).d("Connected to server with userId: ${event.userId}") } is ListenTogetherEvent.RoomCreated -> { Timber.tag(TAG).d("Room created: ${event.roomCode}") try { // Register player listener for host val connection = playerConnection val player = connection?.player if (player != null && !playerListenerRegistered) { try { player.addListener(playerListener) playerListenerRegistered = true Timber.tag(TAG).d("Added player listener as host") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to add player listener on room create") } } // Initialize sync state lastSyncedIsPlaying = player?.playWhenReady lastSyncedTrackId = player?.currentMediaItem?.mediaId // If there's already a track loaded, send it to the server player?.currentMetadata?.let { metadata -> Timber.tag(TAG).d("Room created with existing track: ${metadata.title}") // Send track change so server has the current track info sendTrackChangeInternal(metadata) // If host is already playing, immediately send PLAY with current position val isPlaying = player.playWhenReady if (isPlaying) { lastSyncedIsPlaying = true val position = player.currentPosition Timber.tag(TAG).d("Host already playing on room create, sending PLAY at $position") client.sendPlaybackAction(PlaybackActions.PLAY, position = position) } } startQueueSyncObservation() startHeartbeat() startVolumeSyncObservation() } catch (e: Exception) { Timber.tag(TAG).e(e, "Error handling RoomCreated event") } } is ListenTogetherEvent.JoinApproved -> { Timber.tag(TAG).d("Join approved for room: ${event.roomCode}") // Save current mute state before joining as guest so we can restore it on leave saveMuteStateOnJoin() // Apply the full initial state including queue applyPlaybackState( currentTrack = event.state.currentTrack, isPlaying = event.state.isPlaying, position = event.state.position, queue = event.state.queue // bypassBuffer=false (default) for initial join buffer sync ) applyHostVolumeIfNeeded(event.state.volume) updateGuestMuteState() } is ListenTogetherEvent.PlaybackSync -> { Timber.tag(TAG).d("PlaybackSync received: ${event.action.action}") // Guests handle all sync actions. Host should also apply queue ops. val actionType = event.action.action val isQueueOp = actionType == PlaybackActions.QUEUE_ADD || actionType == PlaybackActions.QUEUE_REMOVE || actionType == PlaybackActions.QUEUE_CLEAR if (!isHost || isQueueOp) { handlePlaybackSync(event.action) } } is ListenTogetherEvent.UserJoined -> { Timber.tag(TAG).d("[SYNC] User joined: ${event.username}") // When a new user joins, host should send current track immediately if (isHost) { try { val connection = playerConnection val player = connection?.player player?.currentMetadata?.let { metadata -> Timber.tag(TAG).d("[SYNC] Sending current track to newly joined user: ${metadata.title}") sendTrackChangeInternal(metadata) // If host is currently playing, also send PLAY with current position so the guest jumps to the live position if (player.playWhenReady) { val pos = player.currentPosition Timber.tag(TAG).d("[SYNC] Host playing, sending PLAY at $pos for new joiner") client.sendPlaybackAction(PlaybackActions.PLAY, position = pos) } // Don't send play state - let buffering complete first } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error handling UserJoined event") } } } is ListenTogetherEvent.BufferWait -> { Timber.tag(TAG).d("BufferWait: waiting for ${event.waitingFor.size} users") } is ListenTogetherEvent.BufferComplete -> { Timber.tag(TAG).d("BufferComplete for track: ${event.trackId}") if (!isHost && bufferingTrackId == event.trackId) { bufferCompleteReceivedForTrack = event.trackId applyPendingSyncIfReady() } } is ListenTogetherEvent.SyncStateReceived -> { Timber.tag(TAG).d("SyncStateReceived: playing=${event.state.isPlaying}, pos=${event.state.position}, track=${event.state.currentTrack?.id}") if (!isHost) { handleSyncState(event.state) } } is ListenTogetherEvent.Kicked -> { Timber.tag(TAG).d("Kicked from room: ${event.reason}") cleanup() } is ListenTogetherEvent.Disconnected -> { Timber.tag(TAG).d("Disconnected from server") // Don't cleanup on disconnect - we might reconnect // cleanup() is called when leaving room intentionally or when kicked } is ListenTogetherEvent.Reconnecting -> { Timber.tag(TAG).d("Reconnecting: attempt ${event.attempt}/${event.maxAttempts}") } is ListenTogetherEvent.Reconnected -> { Timber.tag(TAG).d("Reconnected to room: ${event.roomCode}, isHost: ${event.isHost}") try { // Re-register player listener val connection = playerConnection val player = connection?.player if (player != null && !playerListenerRegistered) { try { player.addListener(playerListener) playerListenerRegistered = true Timber.tag(TAG).d("Re-added player listener after reconnect") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to re-add player listener after reconnect") } } // Sync state based on role if (event.isHost) { // Host: only send sync if necessary lastSyncedIsPlaying = player?.playWhenReady lastSyncedTrackId = player?.currentMediaItem?.mediaId val currentMetadata = player?.currentMetadata if (currentMetadata != null) { // Check if server already has the right track (from event.state) val serverTrackId = event.state.currentTrack?.id if (serverTrackId != currentMetadata.id) { Timber.tag(TAG).d("Reconnected as host, server track ($serverTrackId) differs from local (${currentMetadata.id}), syncing") sendTrackChangeInternal(currentMetadata) } else { Timber.tag(TAG).d("Reconnected as host, server already has current track $serverTrackId") } // Small delay before sending play state to let connection stabilize scope.launch { delay(500) try { val currentPlayer = playerConnection?.player if (currentPlayer?.playWhenReady == true) { val pos = currentPlayer.currentPosition Timber.tag(TAG) .d("Reconnected host is playing, sending PLAY at $pos") client.sendPlaybackAction(PlaybackActions.PLAY, position = pos) } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error sending play state after reconnect") } } } } else { // Guest: ALWAYS sync to host's state after reconnection Timber.tag(TAG).d("Reconnected as guest, syncing to host's current state") applyPlaybackState( currentTrack = event.state.currentTrack, isPlaying = event.state.isPlaying, position = event.state.position, queue = event.state.queue, bypassBuffer = true // Reconnect: bypass buffer protocol ) applyHostVolumeIfNeeded(event.state.volume) // Immediately request fresh sync after a short delay to catch live position scope.launch { delay(1000) if (isInRoom && !isHost) { Timber.tag(TAG).d("Requesting fresh sync after reconnect") requestSync() } } } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error handling Reconnected event") } } is ListenTogetherEvent.UserReconnected -> { Timber.tag(TAG).d("User reconnected: ${event.username}") // No action needed - reconnected user already synced via reconnect state } is ListenTogetherEvent.UserDisconnected -> { Timber.tag(TAG).d("User temporarily disconnected: ${event.username}") // User might reconnect, no action needed } is ListenTogetherEvent.HostChanged -> { Timber.tag(TAG).d("Host changed: new host is ${event.newHostName} (${event.newHostId})") val wasHost = isHost val nowIsHost = event.newHostId == userId.value if (wasHost && !nowIsHost) { // Lost host role Timber.tag(TAG).d("Local user lost host role") stopQueueSyncObservation() stopVolumeSyncObservation() if (playerListenerRegistered) { playerConnection?.player?.removeListener(playerListener) playerListenerRegistered = false } // Restore guest mute state since we're now a guest updateGuestMuteState() } else if (!wasHost && nowIsHost) { // Gained host role Timber.tag(TAG).d("Local user gained host role") updateGuestMuteState() // This will restore mute state since we're now host // Register player listener val connection = playerConnection val player = connection?.player if (player != null && !playerListenerRegistered) { try { player.addListener(playerListener) playerListenerRegistered = true Timber.tag(TAG).d("Added player listener as new host") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to add player listener on host transfer") } } // Start the queue and volume sync observations now that we're host startQueueSyncObservation() startVolumeSyncObservation() // Send current player state to guests val metadata = player?.currentMetadata if (metadata != null) { Timber.tag(TAG).d("New host sending current track: ${metadata.title}") sendTrackChangeInternal(metadata) // If currently playing, send play state if (player.playWhenReady) { val position = player.currentPosition Timber.tag(TAG).d("New host is playing, sending PLAY at $position") client.sendPlaybackAction(PlaybackActions.PLAY, position = position) } } } } is ListenTogetherEvent.ConnectionError -> { Timber.tag(TAG).e("Connection error: ${event.error}") cleanup() } else -> { /* Other events handled by UI */ } } } private fun cleanup() { if (lastRole == RoomRole.GUEST) { restoreGuestMuteState() } if (playerListenerRegistered) { playerConnection?.player?.removeListener(playerListener) playerListenerRegistered = false } stopQueueSyncObservation() stopHeartbeat() stopVolumeSyncObservation() // Note: Don't clear shouldBlockPlaybackChanges callback - it checks isInRoom dynamically lastSyncedIsPlaying = null lastSyncedTrackId = null bufferingTrackId = null isSyncing = false bufferCompleteReceivedForTrack = null lastRole = RoomRole.NONE lastSyncActionTime = 0L // Reset sync debouncing ++currentTrackGeneration // Increment to invalidate any pending track-change coroutines } private fun updateGuestMuteState() { // Guests are no longer forced to mute - they can hear the music too val connection = playerConnection ?: return // Just restore any previously forced mute state (should typically be none) restoreGuestMuteState() } /** * Save the current mute state when joining a room as guest. * This allows us to restore it when leaving the room. */ private fun saveMuteStateOnJoin() { val connection = playerConnection ?: return // Only save if we haven't already saved (avoid overwriting on role changes) if (previousMuteState == null) { previousMuteState = connection.isMuted.value Timber.tag(TAG).d("Saved mute state on join: ${previousMuteState}") } } /** * Restore the mute state that was saved when joining the room. * This is called when leaving the room to ensure the user's * mute preference is restored to what it was before joining Listen Together. */ private fun restoreGuestMuteState() { val connection = playerConnection ?: return val savedState = previousMuteState if (savedState != null) { Timber.tag(TAG).d("Restoring mute state on leave: was muted=$savedState, currently muted=${connection.isMuted.value}") connection.setMuted(savedState) } else { // No saved state means we never properly saved (e.g., player wasn't ready on join) // In this case, if currently muted, unmute as a fallback if (connection.isMuted.value) { Timber.tag(TAG).d("No saved mute state on leave, unmuting player as fallback") connection.setMuted(false) } } previousMuteState = null muteForcedByPreference = false } private fun applyHostVolumeIfNeeded(volume: Float?) { if (!syncHostVolumeEnabled.value || isHost || !isInRoom) return val connection = playerConnection ?: return val target = volume?.coerceIn(0f, 1f) ?: return connection.service.playerVolume.value = target } private fun applyPendingSyncIfReady() { val pending = pendingSyncState ?: return val pendingTrackId = pending.currentTrack?.id ?: bufferingTrackId ?: return val completeForTrack = bufferCompleteReceivedForTrack if (completeForTrack != pendingTrackId) return val connection = playerConnection ?: return val player = connection.player Timber.tag(TAG).d("Applying pending sync: track=$pendingTrackId, pos=${pending.position}, play=${pending.isPlaying}") isSyncing = true val targetPos = pending.position val posDiff = kotlin.math.abs(player.currentPosition - targetPos) val willPlay = pending.isPlaying // Use appropriate tolerance based on whether we're about to play val tolerance = if (willPlay && player.playWhenReady) PLAYBACK_POSITION_TOLERANCE_MS else POSITION_TOLERANCE_MS if (posDiff > tolerance) { Timber.tag(TAG).d("Applying pending sync: seeking ${player.currentPosition} -> $targetPos (diff ${posDiff}ms > ${tolerance}ms)") connection.seekTo(targetPos) } else { Timber.tag(TAG).d("Applying pending sync: skipping seek (diff ${posDiff}ms < ${tolerance}ms)") } // Apply play/pause state only if it needs to change if (willPlay && !player.playWhenReady) { Timber.tag(TAG).d("Applying pending sync: starting playback") connection.play() } else if (!willPlay && player.playWhenReady) { Timber.tag(TAG).d("Applying pending sync: pausing playback") connection.pause() } scope.launch { delay(200) isSyncing = false } bufferingTrackId = null pendingSyncState = null bufferCompleteReceivedForTrack = null } private fun handlePlaybackSync(action: PlaybackActionPayload) { val connection = playerConnection if (connection == null) { Timber.tag(TAG).w("Cannot sync playback - no player connection") return } val player = connection.player Timber.tag(TAG).d("Handling playback sync: ${action.action}, position: ${action.position}") isSyncing = true try { when (action.action) { PlaybackActions.PLAY -> { val basePos = action.position ?: 0L val now = System.currentTimeMillis() val adjustedPos = action.serverTime?.let { serverTime -> basePos + kotlin.math.max(0L, now - serverTime) } ?: basePos Timber.tag(TAG).d("Guest: PLAY at position $adjustedPos, currently playing=${player.playWhenReady}") if (bufferingTrackId != null) { pendingSyncState = (pendingSyncState ?: SyncStatePayload( currentTrack = roomState.value?.currentTrack, isPlaying = true, position = adjustedPos, lastUpdate = now )).copy( isPlaying = true, position = adjustedPos, lastUpdate = now ) applyPendingSyncIfReady() return } // Debounce PLAY actions when already playing and in sync val posDiff = kotlin.math.abs(player.currentPosition - adjustedPos) val alreadyPlaying = player.playWhenReady if (alreadyPlaying && posDiff < POSITION_TOLERANCE_MS && (now - lastSyncActionTime) < SYNC_DEBOUNCE_THRESHOLD_MS) { Timber.tag(TAG).d("Guest: PLAY debounced - already playing and in sync (diff ${posDiff}ms)") return } // CRITICAL: Only seek during active playback if position is VERY far off // This prevents interrupting the audio for small drifts if (alreadyPlaying) { if (posDiff > PLAYBACK_POSITION_TOLERANCE_MS) { Timber.tag(TAG).d("Guest: PLAY seeking during playback ${player.currentPosition} -> $adjustedPos (diff ${posDiff}ms)") connection.seekTo(adjustedPos) } else { Timber.tag(TAG).d("Guest: PLAY skipping seek - already playing, drift acceptable (${posDiff}ms < ${PLAYBACK_POSITION_TOLERANCE_MS}ms)") } } else { // When paused/stopped, we can seek more aggressively if (posDiff > POSITION_TOLERANCE_MS) { Timber.tag(TAG).d("Guest: PLAY seeking while paused ${player.currentPosition} -> $adjustedPos (diff ${posDiff}ms)") connection.seekTo(adjustedPos) } // Start playback Timber.tag(TAG).d("Guest: Starting playback") connection.play() } lastSyncActionTime = now } PlaybackActions.PAUSE -> { val pos = action.position ?: 0L val now = System.currentTimeMillis() Timber.tag(TAG).d("Guest: PAUSE at position $pos, currently playing=${player.playWhenReady}") if (bufferingTrackId != null) { pendingSyncState = (pendingSyncState ?: SyncStatePayload( currentTrack = roomState.value?.currentTrack, isPlaying = false, position = pos, lastUpdate = now )).copy( isPlaying = false, position = pos, lastUpdate = now ) applyPendingSyncIfReady() return } // Debounce PAUSE actions when already paused and in sync val posDiff = kotlin.math.abs(player.currentPosition - pos) val alreadyPaused = !player.playWhenReady if (alreadyPaused && posDiff < POSITION_TOLERANCE_MS && (now - lastSyncActionTime) < SYNC_DEBOUNCE_THRESHOLD_MS) { Timber.tag(TAG).d("Guest: PAUSE debounced - already paused and in sync (diff ${posDiff}ms)") return } // Pause playback first if (player.playWhenReady) { Timber.tag(TAG).d("Guest: Pausing playback") connection.pause() } // Only seek if position difference is significant if (posDiff > POSITION_TOLERANCE_MS) { Timber.tag(TAG).d("Guest: PAUSE seeking ${player.currentPosition} -> $pos (diff ${posDiff}ms)") connection.seekTo(pos) } else { Timber.tag(TAG).d("Guest: PAUSE skipping seek (diff ${posDiff}ms < ${POSITION_TOLERANCE_MS}ms)") } lastSyncActionTime = now } PlaybackActions.SEEK -> { val pos = action.position ?: 0L val now = System.currentTimeMillis() // Debounce SEEK actions - don't seek if one just happened if (now - lastSyncActionTime < SYNC_DEBOUNCE_THRESHOLD_MS) { Timber.tag(TAG).d("Guest: SEEK debounced (only ${now - lastSyncActionTime}ms since last sync)") return } // Use larger position tolerance if (kotlin.math.abs(player.currentPosition - pos) > POSITION_TOLERANCE_MS) { Timber.tag(TAG).d("Guest: SEEK to $pos from ${player.currentPosition} (diff > ${POSITION_TOLERANCE_MS}ms)") connection.seekTo(pos) lastSyncActionTime = now } else { Timber.tag(TAG).d("Guest: SEEK ignored (position diff < ${POSITION_TOLERANCE_MS}ms)") } } PlaybackActions.CHANGE_TRACK -> { action.trackInfo?.let { track -> Timber.tag(TAG).d("Guest: CHANGE_TRACK to ${track.title}, queue size=${action.queue?.size}") // Reset sync debounce timer on track change - this is a fresh sync cycle lastSyncActionTime = 0L // If we have a queue, use it! This is the "smart" sync path. if (action.queue != null && action.queue.isNotEmpty()) { val queueTitle = action.queueTitle applyPlaybackState( currentTrack = track, isPlaying = false, // Will be updated by subsequent PLAY or pending sync position = 0, queue = action.queue, queueTitle = queueTitle ) } else { // Fallback to old behavior (network fetch) if no queue provided bufferingTrackId = track.id syncToTrack(track, false, 0) } } } PlaybackActions.SKIP_NEXT -> { Timber.tag(TAG).d("Guest: SKIP_NEXT") connection.seekToNext() } PlaybackActions.SKIP_PREV -> { Timber.tag(TAG).d("Guest: SKIP_PREV") connection.seekToPrevious() } PlaybackActions.QUEUE_ADD -> { val track = action.trackInfo if (track == null) { Timber.tag(TAG).w("QUEUE_ADD missing trackInfo") } else { Timber.tag(TAG).d("Guest: QUEUE_ADD ${track.title}, insertNext=${action.insertNext == true}") scope.launch(Dispatchers.IO) { // Fetch MediaItem via YouTube metadata YouTube.queue(listOf(track.id)).onSuccess { list -> val mediaItem = list.firstOrNull()?.toMediaMetadata()?.copy( suggestedBy = track.suggestedBy )?.toMediaItem() if (mediaItem != null) { launch(Dispatchers.Main) { // Allow internal sync to bypass guest restrictions connection.allowInternalSync = true if (action.insertNext == true) { connection.playNext(mediaItem) } else { connection.addToQueue(mediaItem) } connection.allowInternalSync = false } } else { Timber.tag(TAG).w("QUEUE_ADD failed to resolve media item for ${track.id}") } }.onFailure { Timber.tag(TAG).e(it, "QUEUE_ADD metadata fetch failed") } } } } PlaybackActions.QUEUE_REMOVE -> { val removeId = action.trackId if (removeId.isNullOrEmpty()) { Timber.tag(TAG).w("QUEUE_REMOVE missing trackId") } else { // Find first queue item with matching mediaId after current index val startIndex = player.currentMediaItemIndex + 1 var removeIndex = -1 val total = player.mediaItemCount for (i in startIndex until total) { val id = player.getMediaItemAt(i).mediaId if (id == removeId) { removeIndex = i; break } } if (removeIndex >= 0) { Timber.tag(TAG).d("Guest: QUEUE_REMOVE index=$removeIndex id=$removeId") player.removeMediaItem(removeIndex) } else { Timber.tag(TAG).w("QUEUE_REMOVE id not found in queue: $removeId") } } } PlaybackActions.QUEUE_CLEAR -> { val currentIndex = player.currentMediaItemIndex val count = player.mediaItemCount val itemsAfter = count - (currentIndex + 1) if (itemsAfter > 0) { Timber.tag(TAG).d("Guest: QUEUE_CLEAR removing $itemsAfter items after current") player.removeMediaItems(currentIndex + 1, count - (currentIndex + 1)) } } PlaybackActions.SET_VOLUME -> { applyHostVolumeIfNeeded(action.volume) } PlaybackActions.SYNC_QUEUE -> { val queue = action.queue val queueTitle = action.queueTitle if (queue != null) { Timber.tag(TAG).d("Guest: SYNC_QUEUE size=${queue.size}") // Cancel any pending "smart" sync (e.g. YouTube radio fetch) in favor of this authoritative queue activeSyncJob?.cancel() scope.launch(Dispatchers.Main) { if (playerConnection !== connection) return@launch val player = connection.player // Map TrackInfo to MediaItems val mediaItems = queue.map { track -> track.toMediaMetadata().toMediaItem() } // Try to find current track in new queue to preserve playback state val currentId = player.currentMediaItem?.mediaId var newIndex = -1 if (currentId != null) { newIndex = mediaItems.indexOfFirst { it.mediaId == currentId } } val currentPos = player.currentPosition val wasPlaying = player.isPlaying connection.allowInternalSync = true if (newIndex != -1) { player.setMediaItems(mediaItems, newIndex, currentPos) } else { player.setMediaItems(mediaItems) } connection.allowInternalSync = false // Restore playing state if needed if (wasPlaying && !player.isPlaying) { connection.play() } // Sync queue title try { connection.service.queueTitle = queueTitle } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to set queue title during SYNC_QUEUE") } } } } } } finally { // Minimal delay to prevent feedback loops scope.launch { delay(200) isSyncing = false } } } private fun handleSyncState(state: SyncStatePayload) { Timber.tag(TAG).d("handleSyncState: playing=${state.isPlaying}, pos=${state.position}, track=${state.currentTrack?.id}") applyPlaybackState( currentTrack = state.currentTrack, isPlaying = state.isPlaying, position = state.position, queue = state.queue, bypassBuffer = true // Manual sync: bypass buffer ) applyHostVolumeIfNeeded(state.volume) } private fun applyPlaybackState( currentTrack: TrackInfo?, isPlaying: Boolean, position: Long, queue: List?, queueTitle: String? = null, // New param bypassBuffer: Boolean = false ) { val connection = playerConnection if (connection == null) { Timber.tag(TAG).w("Cannot apply playback state - no player") return } val player = connection.player Timber.tag(TAG).d("Applying playback state: track=${currentTrack?.id}, pos=$position, queue=${queue?.size}, bypassBuffer=$bypassBuffer") // Cancel any pending sync job activeSyncJob?.cancel() // If no track, just pause and clear/set queue if (currentTrack == null) { Timber.tag(TAG).d("No track in state, pausing") val generation = ++currentTrackGeneration scope.launch(Dispatchers.Main) { // Verify we're still on the same track generation (no newer track change arrived) if (currentTrackGeneration != generation) { Timber.tag(TAG).d("Skipping stale track generation: $generation vs current $currentTrackGeneration") return@launch } if (playerConnection !== connection) return@launch isSyncing = true connection.allowInternalSync = true if (queue != null && queue.isNotEmpty()) { val mediaItems = queue.map { it.toMediaMetadata().toMediaItem() } player.setMediaItems(mediaItems) } else if (queue != null) { player.clearMediaItems() } connection.pause() try { connection.service.queueTitle = queueTitle } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to set queue title for empty state") } connection.allowInternalSync = false isSyncing = false } return } bufferingTrackId = currentTrack.id val generation = ++currentTrackGeneration scope.launch(Dispatchers.Main) { // Verify we're still on the same track generation (no newer track change arrived) if (currentTrackGeneration != generation) { Timber.tag(TAG).d("Skipping stale track generation: $generation vs current $currentTrackGeneration (track ${currentTrack.id})") return@launch } if (playerConnection !== connection) return@launch isSyncing = true connection.allowInternalSync = true try { // Re-verify generation before applying media items (critical section) if (currentTrackGeneration != generation) { Timber.tag(TAG).d("Stale generation detected before setMediaItems: $generation vs $currentTrackGeneration") return@launch } // Apply queue/media (same) if (queue != null && queue.isNotEmpty()) { val mediaItems = queue.map { it.toMediaMetadata().toMediaItem() } // Find index of current track var startIndex = mediaItems.indexOfFirst { it.mediaId == currentTrack.id } if (startIndex == -1) { Timber.tag(TAG).w("Current track ${currentTrack.id} not found in queue, defaulting to 0") val singleItem = currentTrack.toMediaMetadata().toMediaItem() // Prepend or fallback? Let's just play the track alone if not in queue player.setMediaItems(listOf(singleItem), 0, position) } else { player.setMediaItems(mediaItems, startIndex, position) } } else { // No queue provided, fallback to loading just the track (or radio) via syncToTrack logic // But we want to avoid double loading. // If queue is null, we might be in a state where we should fetch radio? // But here we assume authoritative state. Timber.tag(TAG).d("No queue in state, loading single track") // Construct single item val item = currentTrack.toMediaMetadata().toMediaItem() player.setMediaItems(listOf(item), 0, position) } connection.seekTo(position) // Always seek immediately to target pos // Sync queue title try { connection.service.queueTitle = queueTitle ?: "Listen Together" } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to set queue title during applyPlaybackState") } if (bypassBuffer) { // Manual sync/reconnect: apply play/pause immediately, no buffer protocol Timber.tag(TAG).d("Bypass buffer: immediately applying play=$isPlaying at pos=$position") // Wait for player to be ready before seek/play var attempts = 0 while (player.playbackState != Player.STATE_READY && attempts < 100) { delay(50) attempts++ } if (player.playbackState == Player.STATE_READY) { Timber.tag(TAG).d("Player ready after ${attempts * 50}ms, seeking to $position") player.seekTo(position) if (isPlaying) { connection.play() Timber.tag(TAG).d("Bypass: PLAY issued") } else { connection.pause() Timber.tag(TAG).d("Bypass: PAUSE issued") } } else { Timber.tag(TAG).w("Player not ready after 5s timeout during bypass sync") } // Clear sync state pendingSyncState = null bufferingTrackId = null bufferCompleteReceivedForTrack = null } else { // Normal sync: pause, store pending, send buffer_ready connection.pause() pendingSyncState = SyncStatePayload( currentTrack = currentTrack, isPlaying = isPlaying, position = position, lastUpdate = System.currentTimeMillis() ) applyPendingSyncIfReady() client.sendBufferReady(currentTrack.id) } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error applying playback state") } finally { connection.allowInternalSync = false delay(200) isSyncing = false } } } private fun syncToTrack(track: TrackInfo, shouldPlay: Boolean, position: Long) { Timber.tag(TAG).d("syncToTrack: ${track.title}, play: $shouldPlay, pos: $position") // Track which buffer-complete we expect for this load bufferingTrackId = track.id val generation = currentTrackGeneration activeSyncJob?.cancel() activeSyncJob = scope.launch(Dispatchers.IO) { try { // Check if a newer track change arrived - skip this load if stale if (currentTrackGeneration != generation) { Timber.tag(TAG).d("Skipping stale syncToTrack for ${track.id} (generation $generation vs $currentTrackGeneration)") isSyncing = false return@launch } // Use YouTube API to play the track by ID YouTube.queue(listOf(track.id)).onSuccess { queue -> Timber.tag(TAG).d("Got queue for track ${track.id}") launch(Dispatchers.Main) { // Final generation check before applying changes if (currentTrackGeneration != generation) { Timber.tag(TAG).d("Skipping stale track application for ${track.id} (generation $generation vs $currentTrackGeneration)") isSyncing = false return@launch } val connection = playerConnection ?: run { isSyncing = false return@launch } if (playerConnection !== connection) { isSyncing = false return@launch } isSyncing = true // Allow internal sync to bypass playback blocking for guests connection.allowInternalSync = true connection.playQueue( YouTubeQueue( endpoint = WatchEndpoint(videoId = track.id), preloadItem = queue.firstOrNull()?.toMediaMetadata() ) ) try { connection.service.queueTitle = "Listen Together" // Set default title } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to set queue title") } connection.allowInternalSync = false // Wait for player to be ready - monitor actual player state var waitCount = 0 while (waitCount < 40) { // Max 2 seconds (40 * 50ms) // Check generation again while waiting if (currentTrackGeneration != generation) { Timber.tag(TAG).d("Generation changed while waiting for player ready - aborting sync for ${track.id}") isSyncing = false return@launch } try { val player = connection.player if (player.playbackState == Player.STATE_READY) { Timber.tag(TAG).d("Player ready after ${waitCount * 50}ms") break } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error checking player state") break } delay(50) waitCount++ } // Do NOT seek here; defer the exact seek until after the server signals buffer-complete // Ensure paused state before signaling ready connection.pause() // Store pending sync (guest will apply seek + play/pause after BufferComplete) pendingSyncState = SyncStatePayload( currentTrack = track, isPlaying = shouldPlay, position = position, lastUpdate = System.currentTimeMillis() ) // Apply immediately if buffer-complete already arrived applyPendingSyncIfReady() // Signal we're ready to play client.sendBufferReady(track.id) Timber.tag(TAG).d("Sent buffer ready for ${track.id}, pending sync stored: pos=$position, play=$shouldPlay") // Minimal delay before accepting sync commands delay(100) isSyncing = false } }.onFailure { e -> Timber.tag(TAG).e(e, "Failed to load track ${track.id}") playerConnection?.allowInternalSync = false isSyncing = false } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error syncing to track") playerConnection?.allowInternalSync = false isSyncing = false } } } // Public API for host actions /** * Connect to the Listen Together server */ fun connect() { Timber.tag(TAG).d("Connecting to server") client.connect() } /** * Disconnect from the server */ fun disconnect() { Timber.tag(TAG).d("Disconnecting from server") cleanup() client.disconnect() } /** * Create a new room */ fun createRoom(username: String) { Timber.tag(TAG).d("Creating room with username: $username") client.createRoom(username) } /** * Join an existing room */ fun joinRoom(roomCode: String, username: String) { Timber.tag(TAG).d("Joining room $roomCode as $username") client.joinRoom(roomCode, username) } /** * Leave the current room */ fun leaveRoom() { Timber.tag(TAG).d("Leaving room") cleanup() client.leaveRoom() } /** * Approve a join request */ fun approveJoin(userId: String) = client.approveJoin(userId) /** * Reject a join request */ fun rejectJoin(userId: String, reason: String? = null) = client.rejectJoin(userId, reason) /** * Kick a user */ fun kickUser(userId: String, reason: String? = null) = client.kickUser(userId, reason) /** * Block a user permanently (internal list) */ fun blockUser(username: String) = client.blockUser(username) /** * Unblock a previously blocked user */ fun unblockUser(username: String) = client.unblockUser(username) /** * Get all currently blocked usernames */ fun getBlockedUsernames(): Set = blockedUsernames.value /** * Transfer host role to another user */ fun transferHost(newHostId: String) = client.transferHost(newHostId) /** * Send track change (host only) - called when host changes track */ fun sendTrackChange(metadata: MediaMetadata) { if (!isHost || isSyncing) return sendTrackChangeInternal(metadata) } /** * Internal track change - bypasses isSyncing check for initial state sync */ private fun sendTrackChangeInternal(metadata: MediaMetadata) { if (!isHost) return // Use a default duration of 3 minutes if duration is 0 or negative val durationMs = if (metadata.duration > 0) metadata.duration.toLong() * 1000 else 180000L val trackInfo = TrackInfo( id = metadata.id, title = metadata.title, artist = metadata.artists.joinToString(", ") { it.name }, album = metadata.album?.title, duration = durationMs, thumbnail = metadata.thumbnailUrl, suggestedBy = metadata.suggestedBy ) Timber.tag(TAG).d("Sending track change: ${trackInfo.title}, duration: $durationMs") // Also grab current queue to send along with track change val currentQueue = try { playerConnection?.queueWindows?.value?.map { it.toTrackInfo() } } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to get current queue") null } val currentTitle = try { playerConnection?.queueTitle?.value } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to get current title") null } client.sendPlaybackAction( PlaybackActions.CHANGE_TRACK, queueTitle = currentTitle, trackInfo = trackInfo, queue = currentQueue ) } private fun startQueueSyncObservation() { if (queueObserverJob?.isActive == true) return Timber.tag(TAG).d("Starting queue sync observation") queueObserverJob = scope.launch { playerConnection?.queueWindows ?.map { windows -> windows.map { it.toTrackInfo() } } ?.distinctUntilChanged() ?.collectLatest { tracks -> if (!isHost || !isInRoom || isSyncing) return@collectLatest delay(500) // Debounce rapid playlist manipulations Timber.tag(TAG).d("Sending SYNC_QUEUE with ${tracks.size} items") val queueTitle = try { playerConnection?.queueTitle?.value } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to get queue title") null } client.sendPlaybackAction( PlaybackActions.SYNC_QUEUE, queueTitle = queueTitle, queue = tracks ) } } } private fun startVolumeSyncObservation() { if (volumeObserverJob?.isActive == true) return Timber.tag(TAG).d("Starting volume sync observation") volumeObserverJob = scope.launch { playerConnection?.service?.playerVolume ?.collectLatest { volume -> if (!isHost || !isInRoom || !syncHostVolumeEnabled.value) return@collectLatest val normalized = volume.coerceIn(0f, 1f) val last = lastSyncedVolume if (last != null && kotlin.math.abs(last - normalized) < 0.01f) return@collectLatest lastSyncedVolume = normalized client.sendPlaybackAction(PlaybackActions.SET_VOLUME, volume = normalized) } } } private fun stopVolumeSyncObservation() { volumeObserverJob?.cancel() volumeObserverJob = null lastSyncedVolume = null } private fun androidx.media3.common.Timeline.Window.toTrackInfo(): TrackInfo { val metadata = mediaItem.metadata ?: return TrackInfo("unknown", "Unknown", "Unknown", "", 0, "") val durationMs = if (metadata.duration > 0) metadata.duration.toLong() * 1000 else 180000L return TrackInfo( id = metadata.id, title = metadata.title, artist = metadata.artists.joinToString(", ") { it.name }, album = metadata.album?.title, duration = durationMs, thumbnail = metadata.thumbnailUrl, suggestedBy = metadata.suggestedBy ) } private fun stopQueueSyncObservation() { queueObserverJob?.cancel() queueObserverJob = null } private fun TrackInfo.toMediaMetadata(): MediaMetadata { return MediaMetadata( id = id, title = title, artists = listOf(Artist(id = "", name = artist)), album = if (album != null) Album(id = "", title = album) else null, duration = (duration / 1000).toInt(), thumbnailUrl = thumbnail, suggestedBy = suggestedBy ) } /** * Request sync state from server (for guests to re-sync) * Call this when a guest presses play/pause to sync with host */ fun requestSync() { if (!isInRoom || isHost) { Timber.tag(TAG).d("requestSync: not applicable (isInRoom=$isInRoom, isHost=$isHost)") return } Timber.tag(TAG).d("Requesting sync from server") client.requestSync() } /** * Clear logs */ fun clearLogs() = client.clearLogs() // Suggestions API /** * Suggest the given track to the host (guest only) */ fun suggestTrack(track: TrackInfo) = client.suggestTrack(track) /** * Approve a suggestion (host only) */ fun approveSuggestion(suggestionId: String) { if (!isHost) return // Send approval; server will insert-next and broadcast once client.approveSuggestion(suggestionId) } /** * Reject a suggestion (host only) */ fun rejectSuggestion(suggestionId: String, reason: String? = null) = client.rejectSuggestion(suggestionId, reason) /** * Force reconnection to server (for manual recovery) */ fun forceReconnect() { Timber.tag(TAG).d("Forcing reconnection") client.forceReconnect() } /** * Get persisted room code if available */ fun getPersistedRoomCode(): String? = client.getPersistedRoomCode() /** * Get current session age */ fun getSessionAge(): Long = client.getSessionAge() // Heartbeat timer private var heartbeatJob: Job? = null private fun startHeartbeat() { if (heartbeatJob?.isActive == true) return heartbeatJob = scope.launch { while (heartbeatJob?.isActive == true && isInRoom && isHost) { delay(15000L) // 15 seconds playerConnection?.player?.let { player -> if (player.playWhenReady && player.playbackState == Player.STATE_READY) { val pos = player.currentPosition Timber.tag(TAG).d("Host heartbeat: sending PLAY at pos $pos") client.sendPlaybackAction(PlaybackActions.PLAY, position = pos) } } } } Timber.tag(TAG).d("Host heartbeat started (15s interval)") } private fun stopHeartbeat() { heartbeatJob?.cancel() heartbeatJob = null Timber.tag(TAG).d("Host heartbeat stopped") } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/listentogether/ListenTogetherServers.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.listentogether import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @Serializable data class ListenTogetherServer( val name: String, val url: String, val location: String, val operator: String ) object ListenTogetherServers { private const val ServersJson = """ [ { "name": "The Meowery", "url": "wss://metroserverx.meowery.eu/ws", "location": "Poland", "operator": "Nyx" } ] """ private val json = Json { ignoreUnknownKeys = true } val servers: List by lazy { json.decodeFromString(ServersJson) } val defaultServerUrl: String get() = servers.first().url fun findByUrl(url: String): ListenTogetherServer? = servers.firstOrNull { it.url == url } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/listentogether/MessageCodec.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.listentogether import com.google.protobuf.MessageLite import com.metrolist.music.listentogether.proto.Listentogether import timber.log.Timber import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream /** * Codec for encoding and decoding messages using Protocol Buffers */ class MessageCodec( var compressionEnabled: Boolean = false ) { companion object { private const val TAG = "MessageCodec" private const val COMPRESSION_THRESHOLD = 100 // Only compress if > 100 bytes } /** * Encode a message using Protocol Buffers */ fun encode(msgType: String, payload: Any?): ByteArray { return encodeProtobuf(msgType, payload) } /** * Decode a protobuf message */ fun decode(data: ByteArray): Pair { return decodeProtobuf(data) } /** * Encode message using Protocol Buffers */ private fun encodeProtobuf(msgType: String, payload: Any?): ByteArray { var payloadBytes = byteArrayOf() var compressed = false if (payload != null) { val protoMsg = toProtoMessage(payload) payloadBytes = protoMsg.toByteArray() // Compress if enabled and payload is large enough if (compressionEnabled && payloadBytes.size > COMPRESSION_THRESHOLD) { val compressedBytes = compressData(payloadBytes) if (compressedBytes.size < payloadBytes.size) { payloadBytes = compressedBytes compressed = true } } } val envelope = Listentogether.Envelope.newBuilder() .setType(msgType) .setPayload(com.google.protobuf.ByteString.copyFrom(payloadBytes)) .setCompressed(compressed) .build() return envelope.toByteArray() } /** * Decode protobuf message */ private fun decodeProtobuf(data: ByteArray): Pair { val envelope = Listentogether.Envelope.parseFrom(data) var payloadBytes = envelope.payload.toByteArray() if (envelope.compressed) { payloadBytes = decompressData(payloadBytes) ?: payloadBytes } return Pair(envelope.type, payloadBytes) } /** * Compress data using GZIP */ private fun compressData(data: ByteArray): ByteArray { val outputStream = ByteArrayOutputStream() GZIPOutputStream(outputStream).use { gzip -> gzip.write(data) } return outputStream.toByteArray() } /** * Decompress GZIP data */ private fun decompressData(data: ByteArray): ByteArray? { return try { val inputStream = ByteArrayInputStream(data) GZIPInputStream(inputStream).use { gzip -> gzip.readBytes() } } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to decompress data") null } } /** * Convert Kotlin objects to protobuf messages */ private fun toProtoMessage(payload: Any): MessageLite { return when (payload) { is CreateRoomPayload -> Listentogether.CreateRoomPayload.newBuilder() .setUsername(payload.username) .build() is JoinRoomPayload -> Listentogether.JoinRoomPayload.newBuilder() .setRoomCode(payload.roomCode) .setUsername(payload.username) .build() is ApproveJoinPayload -> Listentogether.ApproveJoinPayload.newBuilder() .setUserId(payload.userId) .build() is RejectJoinPayload -> Listentogether.RejectJoinPayload.newBuilder() .setUserId(payload.userId) .setReason(payload.reason ?: "") .build() is PlaybackActionPayload -> { val builder = Listentogether.PlaybackActionPayload.newBuilder() .setAction(payload.action) .setPosition(payload.position ?: 0) .setInsertNext(payload.insertNext ?: false) .setVolume(payload.volume ?: 1f) .setServerTime(payload.serverTime ?: 0) payload.trackId?.let { builder.setTrackId(it) } payload.trackInfo?.let { builder.setTrackInfo(trackInfoToProto(it)) } payload.queueTitle?.let { builder.setQueueTitle(it) } payload.queue?.forEach { track -> builder.addQueue(trackInfoToProto(track)) } builder.build() } is BufferReadyPayload -> Listentogether.BufferReadyPayload.newBuilder() .setTrackId(payload.trackId) .build() is KickUserPayload -> Listentogether.KickUserPayload.newBuilder() .setUserId(payload.userId) .setReason(payload.reason ?: "") .build() is SuggestTrackPayload -> { val builder = Listentogether.SuggestTrackPayload.newBuilder() payload.trackInfo.let { builder.setTrackInfo(trackInfoToProto(it)) } builder.build() } is ApproveSuggestionPayload -> Listentogether.ApproveSuggestionPayload.newBuilder() .setSuggestionId(payload.suggestionId) .build() is RejectSuggestionPayload -> Listentogether.RejectSuggestionPayload.newBuilder() .setSuggestionId(payload.suggestionId) .setReason(payload.reason ?: "") .build() is ReconnectPayload -> Listentogether.ReconnectPayload.newBuilder() .setSessionToken(payload.sessionToken) .build() is TransferHostPayload -> Listentogether.TransferHostPayload.newBuilder() .setNewHostId(payload.newHostId) .build() else -> throw IllegalArgumentException("Unsupported payload type: ${payload::class.simpleName}") } } /** * Decode protobuf payload to Kotlin objects */ fun decodePayload(msgType: String, payloadBytes: ByteArray): Any? { if (payloadBytes.isEmpty()) return null return decodeProtobufPayload(msgType, payloadBytes) } /** * Decode protobuf payload */ private fun decodeProtobufPayload(msgType: String, payloadBytes: ByteArray): Any? { return when (msgType) { MessageTypes.ROOM_CREATED -> { val pb = Listentogether.RoomCreatedPayload.parseFrom(payloadBytes) RoomCreatedPayload(pb.roomCode, pb.userId, pb.sessionToken) } MessageTypes.JOIN_REQUEST -> { val pb = Listentogether.JoinRequestPayload.parseFrom(payloadBytes) JoinRequestPayload(pb.userId, pb.username) } MessageTypes.JOIN_APPROVED -> { val pb = Listentogether.JoinApprovedPayload.parseFrom(payloadBytes) JoinApprovedPayload( pb.roomCode, pb.userId, pb.sessionToken, protoToRoomState(pb.state) ) } MessageTypes.JOIN_REJECTED -> { val pb = Listentogether.JoinRejectedPayload.parseFrom(payloadBytes) JoinRejectedPayload(pb.reason) } MessageTypes.USER_JOINED -> { val pb = Listentogether.UserJoinedPayload.parseFrom(payloadBytes) UserJoinedPayload(pb.userId, pb.username) } MessageTypes.USER_LEFT -> { val pb = Listentogether.UserLeftPayload.parseFrom(payloadBytes) UserLeftPayload(pb.userId, pb.username) } MessageTypes.SYNC_PLAYBACK -> { val pb = Listentogether.PlaybackActionPayload.parseFrom(payloadBytes) PlaybackActionPayload( action = pb.action, trackId = pb.trackId.takeIf { it.isNotEmpty() }, position = pb.position.takeIf { it > 0 }, trackInfo = pb.trackInfo?.let { protoToTrackInfo(it) }, insertNext = pb.insertNext.takeIf { it }, queue = pb.queueList?.map { protoToTrackInfo(it) }, queueTitle = pb.queueTitle.takeIf { it.isNotEmpty() }, volume = pb.volume.takeIf { it > 0 }, serverTime = pb.serverTime.takeIf { it > 0 } ) } MessageTypes.BUFFER_WAIT -> { val pb = Listentogether.BufferWaitPayload.parseFrom(payloadBytes) BufferWaitPayload(pb.trackId, pb.waitingForList) } MessageTypes.BUFFER_COMPLETE -> { val pb = Listentogether.BufferCompletePayload.parseFrom(payloadBytes) BufferCompletePayload(pb.trackId) } MessageTypes.ERROR -> { val pb = Listentogether.ErrorPayload.parseFrom(payloadBytes) ErrorPayload(pb.code, pb.message) } MessageTypes.HOST_CHANGED -> { val pb = Listentogether.HostChangedPayload.parseFrom(payloadBytes) HostChangedPayload(pb.newHostId, pb.newHostName) } MessageTypes.KICKED -> { val pb = Listentogether.KickedPayload.parseFrom(payloadBytes) KickedPayload(pb.reason) } MessageTypes.SYNC_STATE -> { val pb = Listentogether.SyncStatePayload.parseFrom(payloadBytes) SyncStatePayload( currentTrack = pb.currentTrack?.let { protoToTrackInfo(it) }, isPlaying = pb.isPlaying, position = pb.position, lastUpdate = pb.lastUpdate, queue = pb.queueList?.map { protoToTrackInfo(it) }, volume = pb.volume.takeIf { it > 0 } ) } MessageTypes.RECONNECTED -> { val pb = Listentogether.ReconnectedPayload.parseFrom(payloadBytes) ReconnectedPayload( pb.roomCode, pb.userId, protoToRoomState(pb.state), pb.isHost ) } MessageTypes.USER_RECONNECTED -> { val pb = Listentogether.UserReconnectedPayload.parseFrom(payloadBytes) UserReconnectedPayload(pb.userId, pb.username) } MessageTypes.USER_DISCONNECTED -> { val pb = Listentogether.UserDisconnectedPayload.parseFrom(payloadBytes) UserDisconnectedPayload(pb.userId, pb.username) } MessageTypes.SUGGESTION_RECEIVED -> { val pb = Listentogether.SuggestionReceivedPayload.parseFrom(payloadBytes) SuggestionReceivedPayload( pb.suggestionId, pb.fromUserId, pb.fromUsername, protoToTrackInfo(pb.trackInfo) ) } MessageTypes.SUGGESTION_APPROVED -> { val pb = Listentogether.SuggestionApprovedPayload.parseFrom(payloadBytes) SuggestionApprovedPayload( pb.suggestionId, protoToTrackInfo(pb.trackInfo) ) } MessageTypes.SUGGESTION_REJECTED -> { val pb = Listentogether.SuggestionRejectedPayload.parseFrom(payloadBytes) SuggestionRejectedPayload(pb.suggestionId, pb.reason.takeIf { it.isNotEmpty() }) } else -> null } } // Helper conversion functions private fun trackInfoToProto(track: TrackInfo): Listentogether.TrackInfo { return Listentogether.TrackInfo.newBuilder() .setId(track.id) .setTitle(track.title) .setArtist(track.artist) .setAlbum(track.album ?: "") .setDuration(track.duration) .setThumbnail(track.thumbnail ?: "") .setSuggestedBy(track.suggestedBy ?: "") .build() } private fun protoToTrackInfo(proto: Listentogether.TrackInfo): TrackInfo { return TrackInfo( id = proto.id, title = proto.title, artist = proto.artist, album = proto.album.takeIf { it.isNotEmpty() }, duration = proto.duration, thumbnail = proto.thumbnail.takeIf { it.isNotEmpty() }, suggestedBy = proto.suggestedBy.takeIf { it.isNotEmpty() } ) } private fun protoToUserInfo(proto: Listentogether.UserInfo): UserInfo { return UserInfo( userId = proto.userId, username = proto.username, isHost = proto.isHost, isConnected = proto.isConnected ) } private fun protoToRoomState(proto: Listentogether.RoomState): RoomState { return RoomState( roomCode = proto.roomCode, hostId = proto.hostId, users = proto.usersList.map { protoToUserInfo(it) }, currentTrack = proto.currentTrack?.let { protoToTrackInfo(it) }, isPlaying = proto.isPlaying, position = proto.position, lastUpdate = proto.lastUpdate, volume = proto.volume, queue = proto.queueList.map { protoToTrackInfo(it) } ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/listentogether/Protocol.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.listentogether import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** * Message types for Listen Together protocol */ object MessageTypes { // Client -> Server const val CREATE_ROOM = "create_room" const val JOIN_ROOM = "join_room" const val LEAVE_ROOM = "leave_room" const val APPROVE_JOIN = "approve_join" const val REJECT_JOIN = "reject_join" const val PLAYBACK_ACTION = "playback_action" const val BUFFER_READY = "buffer_ready" const val KICK_USER = "kick_user" const val TRANSFER_HOST = "transfer_host" const val PING = "ping" const val CHAT = "chat" const val REQUEST_SYNC = "request_sync" const val RECONNECT = "reconnect" const val SUGGEST_TRACK = "suggest_track" const val APPROVE_SUGGESTION = "approve_suggestion" const val REJECT_SUGGESTION = "reject_suggestion" // Server -> Client const val ROOM_CREATED = "room_created" const val JOIN_REQUEST = "join_request" const val JOIN_APPROVED = "join_approved" const val JOIN_REJECTED = "join_rejected" const val USER_JOINED = "user_joined" const val USER_LEFT = "user_left" const val SYNC_PLAYBACK = "sync_playback" const val BUFFER_WAIT = "buffer_wait" const val BUFFER_COMPLETE = "buffer_complete" const val ERROR = "error" const val PONG = "pong" const val HOST_CHANGED = "host_changed" const val KICKED = "kicked" const val SYNC_STATE = "sync_state" const val RECONNECTED = "reconnected" const val USER_RECONNECTED = "user_reconnected" const val USER_DISCONNECTED = "user_disconnected" const val SUGGESTION_RECEIVED = "suggestion_received" const val SUGGESTION_APPROVED = "suggestion_approved" const val SUGGESTION_REJECTED = "suggestion_rejected" } /** * Playback action types */ object PlaybackActions { const val PLAY = "play" const val PAUSE = "pause" const val SEEK = "seek" const val SKIP_NEXT = "skip_next" const val SKIP_PREV = "skip_prev" const val CHANGE_TRACK = "change_track" const val QUEUE_ADD = "queue_add" const val QUEUE_REMOVE = "queue_remove" const val QUEUE_CLEAR = "queue_clear" const val SYNC_QUEUE = "sync_queue" const val SET_VOLUME = "set_volume" } /** * Track information */ @Serializable data class TrackInfo( val id: String, val title: String, val artist: String, val album: String? = null, val duration: Long, // milliseconds val thumbnail: String? = null, @SerialName("suggested_by") val suggestedBy: String? = null ) /** * User information */ @Serializable data class UserInfo( @SerialName("user_id") val userId: String, val username: String, @SerialName("is_host") val isHost: Boolean, @SerialName("is_connected") val isConnected: Boolean = true ) /** * Room state */ @Serializable data class RoomState( @SerialName("room_code") val roomCode: String, @SerialName("host_id") val hostId: String, val users: List, @SerialName("current_track") val currentTrack: TrackInfo? = null, @SerialName("is_playing") val isPlaying: Boolean, val position: Long, // milliseconds @SerialName("last_update") val lastUpdate: Long, // unix timestamp ms val volume: Float = 1f, val queue: List = emptyList() ) // Request payloads @Serializable data class CreateRoomPayload( val username: String ) @Serializable data class JoinRoomPayload( @SerialName("room_code") val roomCode: String, val username: String ) @Serializable data class ApproveJoinPayload( @SerialName("user_id") val userId: String ) @Serializable data class RejectJoinPayload( @SerialName("user_id") val userId: String, val reason: String? = null ) @Serializable data class PlaybackActionPayload( val action: String, @SerialName("track_id") val trackId: String? = null, val position: Long? = null, // milliseconds @SerialName("track_info") val trackInfo: TrackInfo? = null, @SerialName("insert_next") val insertNext: Boolean? = null, val queue: List? = null, @SerialName("queue_title") val queueTitle: String? = null, val volume: Float? = null, @SerialName("server_time") val serverTime: Long? = null ) @Serializable data class BufferReadyPayload( @SerialName("track_id") val trackId: String ) @Serializable data class KickUserPayload( @SerialName("user_id") val userId: String, val reason: String? = null ) @Serializable data class TransferHostPayload( @SerialName("new_host_id") val newHostId: String ) @Serializable data class ChatPayload( val message: String ) // Suggestions payloads @Serializable data class SuggestTrackPayload( @SerialName("track_info") val trackInfo: TrackInfo ) @Serializable data class SuggestionReceivedPayload( @SerialName("suggestion_id") val suggestionId: String, @SerialName("from_user_id") val fromUserId: String, @SerialName("from_username") val fromUsername: String, @SerialName("track_info") val trackInfo: TrackInfo ) @Serializable data class ApproveSuggestionPayload( @SerialName("suggestion_id") val suggestionId: String ) @Serializable data class RejectSuggestionPayload( @SerialName("suggestion_id") val suggestionId: String, val reason: String? = null ) @Serializable data class SuggestionApprovedPayload( @SerialName("suggestion_id") val suggestionId: String, @SerialName("track_info") val trackInfo: TrackInfo ) @Serializable data class SuggestionRejectedPayload( @SerialName("suggestion_id") val suggestionId: String, val reason: String? = null ) // Response payloads @Serializable data class RoomCreatedPayload( @SerialName("room_code") val roomCode: String, @SerialName("user_id") val userId: String, @SerialName("session_token") val sessionToken: String ) @Serializable data class JoinRequestPayload( @SerialName("user_id") val userId: String, val username: String ) @Serializable data class JoinApprovedPayload( @SerialName("room_code") val roomCode: String, @SerialName("user_id") val userId: String, @SerialName("session_token") val sessionToken: String, val state: RoomState ) @Serializable data class JoinRejectedPayload( val reason: String ) @Serializable data class UserJoinedPayload( @SerialName("user_id") val userId: String, val username: String ) @Serializable data class UserLeftPayload( @SerialName("user_id") val userId: String, val username: String ) @Serializable data class BufferWaitPayload( @SerialName("track_id") val trackId: String, @SerialName("waiting_for") val waitingFor: List ) @Serializable data class BufferCompletePayload( @SerialName("track_id") val trackId: String ) @Serializable data class ErrorPayload( val code: String, val message: String ) @Serializable data class ChatMessagePayload( @SerialName("user_id") val userId: String, val username: String, val message: String, val timestamp: Long ) @Serializable data class HostChangedPayload( @SerialName("new_host_id") val newHostId: String, @SerialName("new_host_name") val newHostName: String ) @Serializable data class KickedPayload( val reason: String ) /** * Sync state payload - sent to guest when they request current state */ @Serializable data class SyncStatePayload( @SerialName("current_track") val currentTrack: TrackInfo?, @SerialName("is_playing") val isPlaying: Boolean, val position: Long, @SerialName("last_update") val lastUpdate: Long, val queue: List? = null, val volume: Float? = null ) // Reconnection payloads @Serializable data class ReconnectPayload( @SerialName("session_token") val sessionToken: String ) @Serializable data class ReconnectedPayload( @SerialName("room_code") val roomCode: String, @SerialName("user_id") val userId: String, val state: RoomState, @SerialName("is_host") val isHost: Boolean ) @Serializable data class UserReconnectedPayload( @SerialName("user_id") val userId: String, val username: String ) @Serializable data class UserDisconnectedPayload( @SerialName("user_id") val userId: String, val username: String ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/BetterLyricsProvider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.content.Context import com.metrolist.music.betterlyrics.BetterLyrics import com.metrolist.music.constants.EnableBetterLyricsKey import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get object BetterLyricsProvider : LyricsProvider { override val name = "BetterLyrics" override fun isEnabled(context: Context): Boolean = context.dataStore[EnableBetterLyricsKey] ?: true override suspend fun getLyrics( id: String, title: String, artist: String, duration: Int, album: String?, ): Result = BetterLyrics.getLyrics(title, artist, duration, album) override suspend fun getAllLyrics( id: String, title: String, artist: String, duration: Int, album: String?, callback: (String) -> Unit, ) { BetterLyrics.getAllLyrics(title, artist, duration, album, callback) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/KuGouLyricsProvider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.content.Context import com.metrolist.kugou.KuGou import com.metrolist.music.constants.EnableKugouKey import com.metrolist.music.utils.dataStore import com.metrolist.music.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, album: String?, ): Result = KuGou.getLyrics(title, artist, duration, album) override suspend fun getAllLyrics( id: String, title: String, artist: String, duration: Int, album: String?, callback: (String) -> Unit, ) { KuGou.getAllPossibleLyricsOptions(title, artist, duration, album, callback) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/LrcLibLyricsProvider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.content.Context import com.metrolist.lrclib.LrcLib import com.metrolist.music.constants.EnableLrcLibKey import com.metrolist.music.utils.dataStore import com.metrolist.music.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, album: String?, ): Result = LrcLib.getLyrics(title, artist, duration, album) override suspend fun getAllLyrics( id: String, title: String, artist: String, duration: Int, album: String?, callback: (String) -> Unit, ) { LrcLib.getAllLyrics(title, artist, duration, album, callback) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/LyricsEntry.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import kotlinx.coroutines.flow.MutableStateFlow data class WordTimestamp( val text: String, val startTime: Double, val endTime: Double ) data class LyricsEntry( val time: Long, val text: String, val words: List? = null, val romanizedTextFlow: MutableStateFlow = MutableStateFlow(null), val translatedTextFlow: MutableStateFlow = MutableStateFlow(null), val agent: String? = null, val isBackground: Boolean = false ) : Comparable { override fun compareTo(other: LyricsEntry): Int = (time - other.time).toInt() companion object { val HEAD_LYRICS_ENTRY = LyricsEntry(0L, "") } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/LyricsHelper.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.content.Context import android.util.LruCache import com.metrolist.music.constants.LyricsProviderOrderKey import com.metrolist.music.constants.PreferredLyricsProvider import com.metrolist.music.constants.PreferredLyricsProviderKey import com.metrolist.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND import com.metrolist.music.extensions.toEnum import com.metrolist.music.models.MediaMetadata import com.metrolist.music.utils.NetworkConnectivityObserver import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.reportException import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject class LyricsHelper @Inject constructor( @ApplicationContext private val context: Context, private val networkConnectivity: NetworkConnectivityObserver, ) { private var lyricsProviders = listOf( BetterLyricsProvider, SimpMusicLyricsProvider, LrcLibLyricsProvider, KuGouLyricsProvider, LyricsPlusProvider, YouTubeSubtitleLyricsProvider, YouTubeLyricsProvider ) val preferred = context.dataStore.data .map { preferences -> val providerOrder = preferences[LyricsProviderOrderKey] ?: "" if (providerOrder.isNotBlank()) { // Use the new provider order if available LyricsProviderRegistry.getOrderedProviders(providerOrder) } else { // Fall back to preferred provider logic for backward compatibility val preferredProvider = preferences[PreferredLyricsProviderKey] .toEnum(PreferredLyricsProvider.LRCLIB) when (preferredProvider) { PreferredLyricsProvider.LRCLIB -> listOf( LrcLibLyricsProvider, BetterLyricsProvider, SimpMusicLyricsProvider, KuGouLyricsProvider, LyricsPlusProvider, YouTubeSubtitleLyricsProvider, YouTubeLyricsProvider ) PreferredLyricsProvider.KUGOU -> listOf( KuGouLyricsProvider, BetterLyricsProvider, SimpMusicLyricsProvider, LrcLibLyricsProvider, LyricsPlusProvider, YouTubeSubtitleLyricsProvider, YouTubeLyricsProvider ) PreferredLyricsProvider.BETTER_LYRICS -> listOf( BetterLyricsProvider, SimpMusicLyricsProvider, LrcLibLyricsProvider, KuGouLyricsProvider, LyricsPlusProvider, YouTubeSubtitleLyricsProvider, YouTubeLyricsProvider ) PreferredLyricsProvider.SIMPMUSIC -> listOf( SimpMusicLyricsProvider, BetterLyricsProvider, LrcLibLyricsProvider, KuGouLyricsProvider, LyricsPlusProvider, YouTubeSubtitleLyricsProvider, YouTubeLyricsProvider ) } } }.distinctUntilChanged() .map { providers -> lyricsProviders = providers } private val cache = LruCache>(MAX_CACHE_SIZE) private var currentLyricsJob: Job? = null suspend fun getLyrics(mediaMetadata: MediaMetadata): LyricsWithProvider { currentLyricsJob?.cancel() val cached = cache.get(mediaMetadata.id)?.firstOrNull() if (cached != null) { return LyricsWithProvider(cached.lyrics, cached.providerName) } // Check network connectivity before making network requests // Use synchronous check as fallback if flow doesn't emit val isNetworkAvailable = try { networkConnectivity.isCurrentlyConnected() } catch (e: Exception) { // If network check fails, try to proceed anyway true } if (!isNetworkAvailable) { // Still proceed but return not found to avoid hanging return LyricsWithProvider(LYRICS_NOT_FOUND, "Unknown") } val scope = CoroutineScope(SupervisorJob()) val deferred = scope.async { val cleanedTitle = LyricsUtils.cleanTitleForSearch(mediaMetadata.title) for (provider in lyricsProviders) { if (provider.isEnabled(context)) { try { Timber.tag("LyricsHelper") .d("Trying provider: ${provider.name} for $cleanedTitle") val result = provider.getLyrics( mediaMetadata.id, cleanedTitle, mediaMetadata.artists.joinToString { it.name }, mediaMetadata.duration, mediaMetadata.album?.title, ) result.onSuccess { lyrics -> Timber.tag("LyricsHelper").i("Successfully got lyrics from ${provider.name}") return@async LyricsWithProvider(lyrics, provider.name) }.onFailure { e -> Timber.tag("LyricsHelper").w("${provider.name} failed: ${e.message}") reportException(e) } } catch (e: Exception) { // Catch network-related exceptions like UnresolvedAddressException Timber.tag("LyricsHelper").w("${provider.name} threw exception: ${e.message}") reportException(e) } } else { Timber.tag("LyricsHelper").d("Provider ${provider.name} is disabled") } } Timber.tag("LyricsHelper").w("All providers failed for ${mediaMetadata.title}") return@async LyricsWithProvider(LYRICS_NOT_FOUND, "Unknown") } val result = deferred.await() scope.cancel() return result } suspend fun getAllLyrics( mediaId: String, songTitle: String, songArtists: String, duration: Int, album: String? = null, callback: (LyricsResult) -> Unit, ) { currentLyricsJob?.cancel() val cacheKey = "$songArtists-$songTitle".replace(" ", "") cache.get(cacheKey)?.let { results -> results.forEach { callback(it) } return } // Check network connectivity before making network requests // Use synchronous check as fallback if flow doesn't emit val isNetworkAvailable = try { networkConnectivity.isCurrentlyConnected() } catch (e: Exception) { // If network check fails, try to proceed anyway true } if (!isNetworkAvailable) { // Still try to proceed in case of false negative return } val allResult = mutableListOf() currentLyricsJob = CoroutineScope(SupervisorJob()).launch { val cleanedTitle = LyricsUtils.cleanTitleForSearch(songTitle) lyricsProviders.forEach { provider -> if (provider.isEnabled(context)) { try { provider.getAllLyrics(mediaId, cleanedTitle, songArtists, duration, album) { lyrics -> val result = LyricsResult(provider.name, lyrics) allResult += result callback(result) } } catch (e: Exception) { // Catch network-related exceptions like UnresolvedAddressException reportException(e) } } } cache.put(cacheKey, allResult) } currentLyricsJob?.join() } fun cancelCurrentLyricsJob() { currentLyricsJob?.cancel() currentLyricsJob = null } companion object { private const val MAX_CACHE_SIZE = 3 } } data class LyricsResult( val providerName: String, val lyrics: String, ) data class LyricsWithProvider( val lyrics: String, val provider: String, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/LyricsPlusProvider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.content.Context import com.metrolist.music.constants.EnableLyricsPlus import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import timber.log.Timber @Serializable private data class LyricLineResponse( val time: Long, val duration: Long, val text: String, ) @Serializable private data class LyricsPlusResponse( val type: String? = null, val lyrics: List? = null, val cached: String? = null, ) object LyricsPlusProvider : LyricsProvider { override val name = "LyricsPlus" private val baseUrls = listOf( "https://lyricsplus.binimum.org", "https://lyricsplus.atomix.one", "https://lyricsplus-seven.vercel.app", // might fail since its on vercel... //"https://lyricsplus.prjktla.workers.dev", seems to be easily rate-limited //"https://lyrics-plus-backend.vercel.app", deployment paused ) private val client by lazy { HttpClient(CIO) { install(ContentNegotiation) { json( Json { isLenient = true ignoreUnknownKeys = true }, ) } install(HttpTimeout) { requestTimeoutMillis = 15000 connectTimeoutMillis = 10000 socketTimeoutMillis = 15000 } expectSuccess = false } } override fun isEnabled(context: Context): Boolean = context.dataStore[EnableLyricsPlus] ?: false private suspend fun fetchFromUrl( url: String, title: String, artist: String, duration: Int, album: String?, ): LyricsPlusResponse? = runCatching { val response = client.get("$url/v2/lyrics/get") { parameter("title", title) parameter("artist", artist) parameter("duration", if (duration > 0) duration / 1000 else -1) if (!album.isNullOrBlank()) { parameter("album", album) } parameter("source", "apple,lyricsplus,musixmatch,spotify,musixmatch-word") } if (response.status == HttpStatusCode.OK) { response.body() } else { null } }.getOrNull() private suspend fun fetchLyrics( title: String, artist: String, duration: Int, album: String?, ): LyricsPlusResponse? { for (baseUrl in baseUrls) { try { val result = fetchFromUrl(baseUrl, title, artist, duration, album) if (result != null && !result.lyrics.isNullOrEmpty()) { return result } } catch (e: Exception) { Timber.tag("LyricsPlus").d(e, "Failed to fetch from $baseUrl") continue } } return null } private fun convertToLrc(response: LyricsPlusResponse?): String? { if (response?.lyrics == null || response.lyrics.isEmpty()) { return null } return response.lyrics.mapNotNull { line -> val minutes = line.time / 1000 / 60 val seconds = (line.time / 1000) % 60 val millis = line.time % 1000 / 10 if (line.text.isNotBlank()) { String.format("[%02d:%02d.%02d]%s", minutes, seconds, millis, line.text) } else { null } }.joinToString("\n") } override suspend fun getLyrics( id: String, title: String, artist: String, duration: Int, album: String?, ): Result = runCatching { val response = fetchLyrics(title, artist, duration, album) val lrc = convertToLrc(response) if (lrc.isNullOrBlank()) { throw IllegalStateException("Lyrics unavailable") } lrc } override suspend fun getAllLyrics( id: String, title: String, artist: String, duration: Int, album: String?, callback: (String) -> Unit, ) { getLyrics(id, title, artist, duration, album) .onSuccess { lrcString -> callback(lrcString) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/LyricsProvider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, album: String? = null, ): Result suspend fun getAllLyrics( id: String, title: String, artist: String, duration: Int, album: String? = null, callback: (String) -> Unit, ) { getLyrics(id, title, artist, duration, album).onSuccess(callback) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/LyricsProviderRegistry.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics object LyricsProviderRegistry { private val providerMap = mapOf( "BetterLyrics" to BetterLyricsProvider, "SimpMusic" to SimpMusicLyricsProvider, "LrcLib" to LrcLibLyricsProvider, "KuGou" to KuGouLyricsProvider, "LyricsPlus" to LyricsPlusProvider, "YouTubeSubtitle" to YouTubeSubtitleLyricsProvider, "YouTube" to YouTubeLyricsProvider, ) val providerNames = providerMap.keys.toList() fun getProviderByName(name: String): LyricsProvider? = providerMap[name] fun getProviderName(provider: LyricsProvider): String? = providerMap.entries.find { it.value == provider }?.key fun deserializeProviderOrder(orderString: String): List { if (orderString.isBlank()) { return getDefaultProviderOrder() } return orderString.split(",").map { it.trim() }.filter { it in providerNames } } fun serializeProviderOrder(providers: List): String { return providers.filter { it in providerNames }.joinToString(",") } fun getDefaultProviderOrder(): List = listOf( "BetterLyrics", "SimpMusic", "LrcLib", "KuGou", "LyricsPlus", "YouTubeSubtitle", "YouTube", ) fun getOrderedProviders(orderString: String): List { val order = deserializeProviderOrder(orderString) return order.mapNotNull { getProviderByName(it) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/LyricsTranslationHelper.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.content.Context import com.metrolist.music.api.DeepLService import com.metrolist.music.api.MistralService import com.metrolist.music.api.OpenRouterService import com.metrolist.music.api.OpenRouterStreamingService import com.metrolist.music.constants.LanguageCodeToName import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.LyricsEntity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber import java.util.Locale object LyricsTranslationHelper { private val _status = MutableStateFlow(TranslationStatus.Idle) val status: StateFlow = _status.asStateFlow() // Single source of truth for whether translations are currently active in the UI private val _hasActiveTranslations = MutableStateFlow(false) val hasActiveTranslations: StateFlow = _hasActiveTranslations.asStateFlow() private val _manualTrigger = MutableSharedFlow( extraBufferCapacity = 1, onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST, ) val manualTrigger: SharedFlow = _manualTrigger.asSharedFlow() private val _clearTranslationsTrigger = MutableSharedFlow( extraBufferCapacity = 1, onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST, ) val clearTranslationsTrigger: SharedFlow = _clearTranslationsTrigger.asSharedFlow() private val _translationSaved = MutableSharedFlow( extraBufferCapacity = 1, onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST, ) val translationSaved: SharedFlow = _translationSaved.asSharedFlow() private var translationJob: Job? = null private var isCompositionActive = true // Cache for translations: key = hash of (lyrics content + mode + language), value = list of translations private val translationCache = mutableMapOf>() private fun getCacheKey( lyricsText: String, mode: String, language: String, ): String = "${lyricsText.hashCode()}_${mode}_$language" /** * Try to parse partial JSON array from streaming content * Returns whatever complete lines we can extract so far */ private fun tryParsePartialTranslation( content: String, expectedCount: Int, ): List { // Look for opening bracket val startIdx = content.indexOf('[') if (startIdx == -1) return emptyList() // Try to find complete string entries in the array val result = mutableListOf() var pos = startIdx + 1 var inString = false var escaping = false val currentString = StringBuilder() while (pos < content.length && result.size < expectedCount) { val char = content[pos] when { escaping -> { currentString.append(char) escaping = false } char == '\\' && inString -> { currentString.append(char) escaping = true } char == '"' -> { if (inString) { // End of string - we have a complete entry result.add(currentString.toString()) currentString.clear() inString = false } else { // Start of string inString = true } } inString -> { currentString.append(char) } char == ']' -> { // End of array break } } pos++ } return result } fun getCachedTranslations( lyrics: List, mode: String, language: String, ): List? { val lyricsText = lyrics.filter { it.text.isNotBlank() }.joinToString("\n") { it.text } val key = getCacheKey(lyricsText, mode, language) return translationCache[key] } fun applyCachedTranslations( lyrics: List, mode: String, language: String, ): Boolean { val cached = getCachedTranslations(lyrics, mode, language) ?: return false val nonEmptyEntries = lyrics.mapIndexedNotNull { index, entry -> if (entry.text.isNotBlank()) index to entry else null } if (cached.size >= nonEmptyEntries.size) { nonEmptyEntries.forEachIndexed { idx, (originalIndex, _) -> lyrics[originalIndex].translatedTextFlow.value = cached[idx] } return true } return false } fun triggerManualTranslation() { _manualTrigger.tryEmit(Unit) } fun triggerClearTranslations() { _hasActiveTranslations.value = false _clearTranslationsTrigger.tryEmit(Unit) } fun hasTranslations(lyricsEntity: LyricsEntity?): Boolean = !lyricsEntity?.translatedLyrics.isNullOrBlank() fun clearTranslations(lyricsEntity: LyricsEntity): LyricsEntity = lyricsEntity.copy( translatedLyrics = "", translationLanguage = "", translationMode = "", ) fun resetStatus() { _status.value = TranslationStatus.Idle } fun clearCache() { translationCache.clear() } fun setCompositionActive(active: Boolean) { isCompositionActive = active } fun cancelTranslation() { isCompositionActive = false translationJob?.cancel() translationJob = null } /** * Load translations from database into lyrics entries */ fun loadTranslationsFromDatabase( lyrics: List, lyricsEntity: LyricsEntity?, targetLanguage: String, mode: String, ) { // Always clear translations first lyrics.forEach { it.translatedTextFlow.value = null } // Only load if all conditions are met if (lyricsEntity?.translatedLyrics.isNullOrBlank()) { _hasActiveTranslations.value = false return } if (lyricsEntity.translationLanguage != targetLanguage) { _hasActiveTranslations.value = false return } if (lyricsEntity.translationMode != mode) { _hasActiveTranslations.value = false return } val translatedLines = lyricsEntity.translatedLyrics.lines() val nonEmptyEntries = lyrics.mapIndexedNotNull { index, entry -> if (entry.text.isNotBlank()) index to entry else null } nonEmptyEntries.forEachIndexed { idx, (originalIndex, _) -> if (idx < translatedLines.size) { lyrics[originalIndex].translatedTextFlow.value = translatedLines[idx] } } // Also populate the cache with these translations so future re-translations don't need API calls // This ensures translations persist through app restarts (loaded from DB) without wasting API calls val lyricsText = lyrics.filter { it.text.isNotBlank() }.joinToString("\n") { it.text } val cacheKey = getCacheKey(lyricsText, mode, targetLanguage) translationCache[cacheKey] = translatedLines _hasActiveTranslations.value = true } fun translateLyrics( lyrics: List, targetLanguage: String, apiKey: String, baseUrl: String, model: String, mode: String, scope: CoroutineScope, context: Context, provider: String = "OpenRouter", deeplApiKey: String = "", deeplFormality: String = "default", useStreaming: Boolean = true, songId: String = "", database: MusicDatabase? = null, systemPrompt: String = "", ) { translationJob?.cancel() _status.value = TranslationStatus.Translating // Clear existing translations to indicate re-translation lyrics.forEach { it.translatedTextFlow.value = null } translationJob = scope.launch(Dispatchers.IO) { try { // Validate inputs val effectiveApiKey = if (provider == "DeepL") deeplApiKey else apiKey if (effectiveApiKey.isBlank()) { _status.value = TranslationStatus.Error(context.getString(com.metrolist.music.R.string.ai_error_api_key_required)) return@launch } if (lyrics.isEmpty()) { _status.value = TranslationStatus.Error(context.getString(com.metrolist.music.R.string.ai_error_no_lyrics)) return@launch } // Filter out empty lines and keep track of their indices val nonEmptyEntries = lyrics.mapIndexedNotNull { index, entry -> if (entry.text.isNotBlank()) index to entry else null } if (nonEmptyEntries.isEmpty()) { _status.value = TranslationStatus.Error(context.getString(com.metrolist.music.R.string.ai_error_lyrics_empty)) return@launch } // Create text from non-empty lines only val fullText = nonEmptyEntries.joinToString("\n") { it.second.text } // Check cache first val cacheKey = getCacheKey(fullText, mode, targetLanguage) val cachedTranslations = translationCache[cacheKey] if (cachedTranslations != null && cachedTranslations.size >= nonEmptyEntries.size) { // Use cached translations nonEmptyEntries.forEachIndexed { idx, (originalIndex, _) -> if (idx < cachedTranslations.size) { lyrics[originalIndex].translatedTextFlow.value = cachedTranslations[idx] } } _hasActiveTranslations.value = true _status.value = TranslationStatus.Success // Persist cached translations to DB so loadTranslationsFromDatabase can't // overwrite them with a stale empty entity (e.g. after an untranslate race). if (songId.isNotBlank() && database != null) { try { val currentLyrics = database.lyrics(songId).first() if (currentLyrics != null && currentLyrics.translatedLyrics.isNullOrBlank()) { database.query { upsert( currentLyrics.copy( translatedLyrics = cachedTranslations.joinToString("\n"), translationLanguage = targetLanguage, translationMode = mode, ), ) } _translationSaved.tryEmit(Unit) } } catch (e: Exception) { Timber.e(e, "Failed to persist cached translations to database") } } delay(3000) if (_status.value is TranslationStatus.Success && isCompositionActive) { _status.value = TranslationStatus.Idle } return@launch } // Validate language for all modes if (targetLanguage.isBlank()) { _status.value = TranslationStatus.Error(context.getString(com.metrolist.music.R.string.ai_error_language_required)) return@launch } // Convert language code to full language name for better AI understanding val fullLanguageName = LanguageCodeToName[targetLanguage] ?: try { Locale.forLanguageTag(targetLanguage).displayLanguage.takeIf { it.isNotBlank() && it != targetLanguage } } catch (e: Exception) { null } ?: targetLanguage val result = if (provider == "DeepL") { Timber.d("Using DeepL for translation") // DeepL only supports translation mode DeepLService.translate( text = fullText, targetLanguage = targetLanguage, apiKey = deeplApiKey, formality = deeplFormality, ) } else if (provider == "Mistral") { Timber.d("Using Mistral for translation") // Use Mistral API directly MistralService.translate( text = fullText, targetLanguage = fullLanguageName, apiKey = apiKey, model = model, mode = mode, customSystemPrompt = systemPrompt, ) } else if (useStreaming && provider != "Custom") { Timber.d("Using streaming for translation with provider: $provider") // Use streaming for supported providers var translatedLines: List? = null var hasError = false var errorMessage = "" val contentAccumulator = StringBuilder() OpenRouterStreamingService .streamTranslation( text = fullText, targetLanguage = fullLanguageName, apiKey = apiKey, baseUrl = baseUrl, model = model, mode = mode, customSystemPrompt = systemPrompt, ).collect { chunk -> Timber.v("Received streaming chunk: $chunk") when (chunk) { is OpenRouterStreamingService.StreamChunk.Content -> { // Accumulate content for progressive parsing contentAccumulator.append(chunk.text) // Try to parse partial content and update UI progressively val partialContent = contentAccumulator.toString() val partialResult = tryParsePartialTranslation(partialContent, nonEmptyEntries.size) if (partialResult.isNotEmpty()) { // Update lyrics with partial translations as they become available partialResult.forEachIndexed { idx, translation -> if (idx < nonEmptyEntries.size && translation.isNotBlank()) { val originalIndex = nonEmptyEntries[idx].first lyrics[originalIndex].translatedTextFlow.value = translation } } _status.value = TranslationStatus.Translating } } is OpenRouterStreamingService.StreamChunk.Complete -> { Timber.d("Streaming complete with ${chunk.translatedLines.size} lines") translatedLines = chunk.translatedLines } is OpenRouterStreamingService.StreamChunk.Error -> { Timber.e("Streaming error: ${chunk.message}") hasError = true errorMessage = chunk.message } } } Timber.d("Streaming collection complete. hasError=$hasError, translatedLines=${translatedLines?.size}") if (hasError) { Result.failure(Exception(errorMessage)) } else if (translatedLines != null) { Result.success(translatedLines) } else { Result.failure(Exception("No translation received")) } } else { Timber.d("Using non-streaming for translation") // Use non-streaming for Custom provider or when streaming is disabled OpenRouterService.translate( text = fullText, targetLanguage = fullLanguageName, apiKey = apiKey, baseUrl = baseUrl, model = model, mode = mode, customSystemPrompt = systemPrompt, ) } result .onSuccess { translatedLines -> // Check if composition is still active before updating state if (!isCompositionActive) { return@onSuccess } // Cache the translations val cacheKey = getCacheKey(fullText, mode, targetLanguage) translationCache[cacheKey] = translatedLines // Save to database if songId is provided if (songId.isNotBlank() && database != null) { scope.launch(Dispatchers.IO) { try { val currentLyrics = database.lyrics(songId).first() if (currentLyrics != null) { database.query { upsert( currentLyrics.copy( translatedLyrics = translatedLines.joinToString("\n"), translationLanguage = targetLanguage, translationMode = mode, ), ) } // Signal that translations have been saved _translationSaved.tryEmit(Unit) } } catch (e: Exception) { timber.log.Timber.e(e, "Failed to save translated lyrics to database") } } } // Map translations back to original non-empty entries only val expectedCount = nonEmptyEntries.size when { translatedLines.size >= expectedCount -> { // Perfect match or more - map to non-empty entries nonEmptyEntries.forEachIndexed { idx, (originalIndex, _) -> lyrics[originalIndex].translatedTextFlow.value = translatedLines[idx] } _hasActiveTranslations.value = true _status.value = TranslationStatus.Success } translatedLines.size < expectedCount -> { // Fewer translations than expected - map what we have translatedLines.forEachIndexed { idx, translation -> if (idx < nonEmptyEntries.size) { val originalIndex = nonEmptyEntries[idx].first lyrics[originalIndex].translatedTextFlow.value = translation } } _hasActiveTranslations.value = true _status.value = TranslationStatus.Success } else -> { _status.value = TranslationStatus.Error(context.getString(com.metrolist.music.R.string.ai_error_unexpected)) } } // Auto-hide success message after 3 seconds delay(3000) if (_status.value is TranslationStatus.Success && isCompositionActive) { _status.value = TranslationStatus.Idle } }.onFailure { error -> if (!isCompositionActive) { return@onFailure } val errorMessage = error.message ?: context.getString(com.metrolist.music.R.string.ai_error_unknown) // Show error in UI _status.value = TranslationStatus.Error(errorMessage) } } catch (e: Exception) { // Ignore cancellation exceptions or if composition is no longer active if (e !is kotlinx.coroutines.CancellationException && isCompositionActive) { val errorMessage = e.message ?: context.getString(com.metrolist.music.R.string.ai_error_translation_failed) _status.value = TranslationStatus.Error(errorMessage) } } } } sealed class TranslationStatus { data object Idle : TranslationStatus() data object Translating : TranslationStatus() data object Success : TranslationStatus() data class Error( val message: String, ) : TranslationStatus() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/LyricsUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.text.format.DateUtils import com.atilika.kuromoji.ipadic.Tokenizer import com.github.promeg.pinyinhelper.Pinyin import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.util.Locale @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 cleanTitleForSearch(title: String): String { return title.replace(Regex("\\s*[(\\[].*?[)\\]]"), "").trim() } // Regex for rich sync format: [MM:SS.mm] word word ... private val RICH_SYNC_LINE_REGEX = "\\[(\\d{1,2}):(\\d{2})\\.(\\d{2,3})\\](.+)".toRegex() private val RICH_SYNC_WORD_REGEX = "<(\\d{1,2}):(\\d{2})\\.(\\d{2,3})>\\s*([^<]+)".toRegex() // Regex for agent and background markers private val AGENT_REGEX = "\\{agent:([^}]+)\\}".toRegex() private val BACKGROUND_REGEX = "^\\{bg\\}".toRegex() private val KANA_ROMAJI_MAP: Map = mapOf( // Digraphs (Yōon - combinations like kya, sho) "キャ" to "kya", "キュ" to "kyu", "キョ" to "kyo", "シャ" to "sha", "シュ" to "shu", "ショ" to "sho", "チャ" to "cha", "チュ" to "chu", "チョ" to "cho", "ニャ" to "nya", "ニュ" to "nyu", "ニョ" to "nyo", "ヒャ" to "hya", "ヒュ" to "hyu", "ヒョ" to "hyo", "ミャ" to "mya", "ミュ" to "myu", "ミョ" to "myo", "リャ" to "rya", "リュ" to "ryu", "リョ" to "ryo", "ギャ" to "gya", "ギュ" to "gyu", "ギョ" to "gyo", "ジャ" to "ja", "ジュ" to "ju", "ジョ" to "jo", "ヂャ" to "ja", "ヂュ" to "ju", "ヂョ" to "jo", "ビャ" to "bya", "ビュ" to "byu", "ビョ" to "byo", "ピャ" to "pya", "ピュ" to "pyu", "ピョ" to "pyo", // Basic Katakana Characters "ア" to "a", "イ" to "i", "ウ" to "u", "エ" to "e", "オ" to "o", "カ" to "ka", "キ" to "ki", "ク" to "ku", "ケ" to "ke", "コ" to "ko", "サ" to "sa", "シ" to "shi", "ス" to "su", "セ" to "se", "ソ" to "so", "タ" to "ta", "チ" to "chi", "ツ" to "tsu", "テ" to "te", "ト" to "to", "ナ" to "na", "ニ" to "ni", "ヌ" to "nu", "ネ" to "ne", "ノ" to "no", "ハ" to "ha", "ヒ" to "hi", "フ" to "fu", "ヘ" to "he", "ホ" to "ho", "マ" to "ma", "ミ" to "mi", "ム" to "mu", "メ" to "me", "モ" to "mo", "ヤ" to "ya", "ユ" to "yu", "ヨ" to "yo", "ラ" to "ra", "リ" to "ri", "ル" to "ru", "レ" to "re", "ロ" to "ro", "ワ" to "wa", "ヲ" to "o", "ン" to "n", // Dakuten (voiced consonants) "ガ" to "ga", "ギ" to "gi", "グ" to "gu", "ゲ" to "ge", "ゴ" to "go", "ザ" to "za", "ジ" to "ji", "ズ" to "zu", "ゼ" to "ze", "ゾ" to "zo", "ダ" to "da", "ヂ" to "ji", "ヅ" to "zu", "デ" to "de", "ド" to "do", // Handakuten (p-sounds for 'h' group) "バ" to "ba", "ビ" to "bi", "ブ" to "bu", "ベ" to "be", "ボ" to "bo", "パ" to "pa", "ピ" to "pi", "プ" to "pu", "ペ" to "pe", "ポ" to "po", // Chōonpu (long vowel mark) "ー" to "" ) private val HANGUL_ROMAJA_MAP: Map> = mapOf( "cho" to mapOf( "ᄀ" to "g", "ᄁ" to "kk", "ᄂ" to "n", "ᄃ" to "d", "ᄄ" to "tt", "ᄅ" to "r", "ᄆ" to "m", "ᄇ" to "b", "ᄈ" to "pp", "ᄉ" to "s", "ᄊ" to "ss", "ᄋ" to "", "ᄌ" to "j", "ᄍ" to "jj", "ᄎ" to "ch", "ᄏ" to "k", "ᄐ" to "t", "ᄑ" to "p", "ᄒ" to "h" ), "jung" to mapOf( "ᅡ" to "a", "ᅢ" to "ae", "ᅣ" to "ya", "ᅤ" to "yae", "ᅥ" to "eo", "ᅦ" to "e", "ᅧ" to "yeo", "ᅨ" to "ye", "ᅩ" to "o", "ᅪ" to "wa", "ᅫ" to "wae", "ᅬ" to "oe", "ᅭ" to "yo", "ᅮ" to "u", "ᅯ" to "wo", "ᅰ" to "we", "ᅱ" to "wi", "ᅲ" to "yu", "ᅳ" to "eu", "ᅴ" to "eui", "ᅵ" to "i" ), "jong" to mapOf( "ᆨ" to "k", "ᆨᄋ" to "g", "ᆨᄂ" to "ngn", "ᆨᄅ" to "ngn", "ᆨᄆ" to "ngm", "ᆨᄒ" to "kh", "ᆩ" to "kk", "ᆩᄋ" to "kg", "ᆩᄂ" to "ngn", "ᆩᄅ" to "ngn", "ᆩᄆ" to "ngm", "ᆩᄒ" to "kh", "ᆪ" to "k", "ᆪᄋ" to "ks", "ᆪᄂ" to "ngn", "ᆪᄅ" to "ngn", "ᆪᄆ" to "ngm", "ᆪᄒ" to "kch", "ᆫ" to "n", "ᆫᄅ" to "ll", "ᆬ" to "n", "ᆬᄋ" to "nj", "ᆬᄂ" to "nn", "ᆬᄅ" to "nn", "ᆬᄆ" to "nm", "ᆬㅎ" to "nch", "ᆭ" to "n", "ᆭᄋ" to "nh", "ᆭᄅ" to "nn", "ᆮ" to "t", "ᆮᄋ" to "d", "ᆮᄂ" to "nn", "ᆮᄅ" to "nn", "ᆮᄆ" to "nm", "ᆮᄒ" to "th", "ᆯ" to "l", "ᆯᄋ" to "r", "ᆯᄂ" to "ll", "ᆯᄅ" to "ll", "ᆰ" to "k", "ᆰᄋ" to "lg", "ᆰᄂ" to "ngn", "ᆰᄅ" to "ngn", "ᆰᄆ" to "ngm", "ᆰᄒ" to "lkh", "ᆱ" to "m", "ᆱᄋ" to "lm", "ᆱᄂ" to "mn", "ᆱᄅ" to "mn", "ᆱᄆ" to "mm", "ᆱᄒ" to "lmh", "ᆲ" to "p", "ᆲᄋ" to "lb", "ᆲᄂ" to "mn", "ᆲᄅ" to "mn", "ᆲᄆ" to "mm", "ᆲᄒ" to "lph", "ᆳ" to "t", "ᆳᄋ" to "ls", "ᆳᄂ" to "nn", "ᆳᄅ" to "nn", "ᆳᄆ" to "nm", "ᆳᄒ" to "lsh", "ᆴ" to "t", "ᆴᄋ" to "lt", "ᆴᄂ" to "nn", "ᆴᄅ" to "nn", "ᆴᄆ" to "nm", "ᆴᄒ" to "lth", "ᆵ" to "p", "ᆵᄋ" to "lp", "ᆵᄂ" to "mn", "ᆵᄅ" to "mn", "ᆵᄆ" to "mm", "ᆵᄒ" to "lph", "ᆶ" to "l", "ᆶᄋ" to "lh", "ᆶᄂ" to "ll", "ᆶᄅ" to "ll", "ᆶᄆ" to "lm", "ᆶᄒ" to "lh", "ᆷ" to "m", "ᆷᄅ" to "mn", "ᆸ" to "p", "ᆸᄋ" to "b", "ᆸᄂ" to "mn", "ᆸᄅ" to "mn", "ᆸᄆ" to "mm", "ᆸᄒ" to "ph", "ᆹ" to "p", "ᆹᄋ" to "ps", "ᆹᄂ" to "mn", "ᆹᄅ" to "mn", "ᆹᄆ" to "mm", "ᆹᄒ" to "psh", "ᆺ" to "t", "ᆺᄋ" to "s", "ᆺᄂ" to "nn", "ᆺᄅ" to "nn", "ᆺᄆ" to "nm", "ᆺᄒ" to "sh", "ᆻ" to "t", "ᆻᄋ" to "ss", "ᆻᄂ" to "tn", "ᆻᄅ" to "tn", "ᆻᄆ" to "nm", "ᆻᄒ" to "th", "ᆼ" to "ng", "ᆽ" to "t", "ᆽᄋ" to "j", "ᆽᄂ" to "nn", "ᆽᄅ" to "nn", "ᆽᄆ" to "nm", "ᆽᄒ" to "ch", "ᆾ" to "t", "ᆾᄋ" to "ch", "ᆾᄂ" to "nn", "ᆾᄅ" to "nn", "ᆾᄆ" to "nm", "ᆾᄒ" to "ch", "ᆿ" to "k", "ᆿᄋ" to "k", "ᆿᄂ" to "ngn", "ᆿᄅ" to "ngn", "ᆿᄆ" to "ngm", "ᆿᄒ" to "kh", "ᇀ" to "t", "ᇀᄋ" to "t", "ᇀᄂ" to "nn", "ᇀᄅ" to "nn", "ᇀᄆ" to "nm", "ᇀᄒ" to "th", "ᇁ" to "p", "ᇁᄋ" to "p", "ᇁᄂ" to "mn", "ᇁᄅ" to "mn", "ᇁᄆ" to "mm", "ᇁᄒ" to "ph", "ᇂ" to "t", "ᇂᄋ" to "h", "ᇂᄂ" to "nn", "ᇂᄅ" to "nn", "ᇂᄆ" to "mm", "ᇂᄒ" to "t", "ᇂᄀ" to "k" ) ) private val DEVANAGARI_ROMAJI_MAP: Map = mapOf( "अ" to "a", "आ" to "aa", "इ" to "i", "ई" to "ee", "उ" to "u", "ऊ" to "oo", "ऋ" to "ri", "ए" to "e", "ऐ" to "ai", "ओ" to "o", "औ" to "au", "क" to "k", "ख" to "kh", "ग" to "g", "घ" to "gh", "ङ" to "ng", "च" to "ch", "छ" to "chh", "ज" to "j", "झ" to "jh", "ञ" to "ny", "ट" to "t", "ठ" to "th", "ड" to "d", "ढ" to "dh", "ण" to "n", "त" to "t", "थ" to "th", "द" to "d", "ध" to "dh", "न" to "n", "प" to "p", "फ" to "ph", "ब" to "b", "भ" to "bh", "म" to "m", "य" to "y", "र" to "r", "ल" to "l", "व" to "v", "श" to "sh", "ष" to "sh", "स" to "s", "ह" to "h", "क्ष" to "ksh", "त्र" to "tr", "ज्ञ" to "gy", "श्र" to "shr", "ा" to "aa", "ि" to "i", "ी" to "ee", "ु" to "u", "ू" to "oo", "ृ" to "ri", "े" to "e", "ै" to "ai", "ो" to "o", "ौ" to "au", "ं" to "n", "ः" to "h", "ँ" to "n", "़" to "", "्" to "", "०" to "0", "१" to "1", "२" to "2", "३" to "3", "४" to "4", "५" to "5", "६" to "6", "७" to "7", "८" to "8", "९" to "9", "ॐ" to "Om", "ऽ" to "", "क़" to "q", "ख़" to "kh", "ग़" to "g", "ज़" to "z", "ड़" to "r", "ढ़" to "rh", "फ़" to "f", "य़" to "y", // Decomposed characters with Nukta "क\u093C" to "q", "ख\u093C" to "kh", "ग\u093C" to "g", "ज\u093C" to "z", "ड\u093C" to "r", "ढ\u093C" to "rh", "फ\u093C" to "f", "य\u093C" to "y" ) private val GURMUKHI_ROMAJI_MAP: Map = mapOf( "ੳ" to "o", "ਅ" to "a", "ੲ" to "e", "ਸ" to "s", "ਹ" to "h", "ਕ" to "k", "ਖ" to "kh", "ਗ" to "g", "ਘ" to "gh", "ਙ" to "ng", "ਚ" to "ch", "ਛ" to "chh", "ਜ" to "j", "ਝ" to "jh", "ਞ" to "ny", "ਟ" to "t", "ਠ" to "th", "ਡ" to "d", "ਢ" to "dh", "ਣ" to "n", "ਤ" to "t", "ਥ" to "th", "ਦ" to "d", "ਧ" to "dh", "ਨ" to "n", "ਪ" to "p", "ਫ" to "ph", "ਬ" to "b", "ਭ" to "bh", "ਮ" to "m", "ਯ" to "y", "ਰ" to "r", "ਲ" to "l", "ਵ" to "v", "ੜ" to "r", "ਸ਼" to "sh", "ਖ਼" to "kh", "ਗ਼" to "g", "ਜ਼" to "z", "ਫ਼" to "f", "ਲ਼" to "l", "ਾ" to "aa", "ਿ" to "i", "ੀ" to "ee", "ੁ" to "u", "ੂ" to "oo", "ੇ" to "e", "ੈ" to "ai", "ੋ" to "o", "ੌ" to "au", "ੰ" to "n", "ਂ" to "n", "ੱ" to "", "੍" to "", "਼" to "", "ੴ" to "Ek Onkar", "੦" to "0", "੧" to "1", "੨" to "2", "੩" to "3", "੪" to "4", "੫" to "5", "੬" to "6", "੭" to "7", "੮" to "8", "੯" to "9" ) private val GENERAL_CYRILLIC_ROMAJI_MAP: Map = mapOf( "А" to "A", "Б" to "B", "В" to "V", "Г" to "G", "Ґ" to "G", "Д" to "D", "Ѓ" to "Ǵ", "Ђ" to "Đ", "Е" to "E", "Ё" to "Yo", "Є" to "Ye", "Ж" to "Zh", "З" to "Z", "Ѕ" to "Dz", "И" to "I", "І" to "I", "Ї" to "Yi", "Й" to "Y", "Ј" to "Y", "К" to "K", "Л" to "L", "Љ" to "Ly", "М" to "M", "Н" to "N", "Њ" to "Ny", "О" to "O", "П" to "P", "Р" to "R", "С" to "S", "Т" to "T", "Ћ" to "Ć", "У" to "U", "Ў" to "Ŭ", "Ф" to "F", "Х" to "Kh", "Ц" to "Ts", "Ч" to "Ch", "Џ" to "Dž", "Ш" to "Sh", "Щ" to "Shch", "Ъ" to "ʺ", "Ы" to "Y", "Ь" to "ʹ", "Э" to "E", "Ю" to "Yu", "Я" to "Ya", "Ѡ" to "O", "Ѣ" to "Ya", "Ѥ" to "Ye", "Ѧ" to "Ya", "Ѩ" to "Ya", "Ѫ" to "U", "Ѭ" to "Yu", "Ѯ" to "Ks", "Ѱ" to "Ps", "Ѳ" to "F", "Ѵ" to "I", "Ѷ" to "I", "Ғ" to "Gh", "Ҕ" to "G", "Җ" to "Zh", "Ҙ" to "Dz", "Қ" to "Q", "Ҝ" to "K", "Ҟ" to "K", "Ҡ" to "K", "Ң" to "Ng", "Ҥ" to "Ng", "Ҧ" to "P", "Ҩ" to "O", "Ҫ" to "S", "Ҭ" to "T", "Ү" to "U", "Ұ" to "U", "Ҳ" to "Kh", "Ҵ" to "Ts", "Ҷ" to "Ch", "Ҹ" to "Ch", "Һ" to "H", "Ҽ" to "Ch", "Ҿ" to "Ch", "Ќ" to "Ḱ", "Ө" to "Ö", "а" to "a", "б" to "b", "в" to "v", "г" to "g", "ґ" to "g", "д" to "d", "ѓ" to "ǵ", "ђ" to "đ", "е" to "e", "ё" to "yo", "є" to "ye", "ж" to "zh", "з" to "z", "ѕ" to "dz", "и" to "i", "і" to "i", "ї" to "yi", "й" to "y", "ј" to "y", "к" to "k", "л" to "l", "љ" to "ly", "м" to "m", "н" to "n", "њ" to "ny", "о" to "o", "п" to "p", "р" to "r", "с" to "s", "т" to "t", "ћ" to "ć", "у" to "u", "ў" to "ŭ", "ф" to "f", "х" to "kh", "ц" to "ts", "ч" to "ch", "џ" to "dž", "ш" to "sh", "щ" to "shch", "ъ" to "ʺ", "ы" to "y", "ь" to "ʹ", "э" to "e", "ю" to "yu", "я" to "ya", "ѡ" to "o", "ѣ" to "ya", "ѥ" to "ye", "ѧ" to "ya", "ѩ" to "ya", "ѫ" to "u", "ѭ" to "yu", "ѯ" to "ks", "ѱ" to "ps", "ѳ" to "f", "ѵ" to "i", "ѷ" to "i", "ғ" to "gh", "ҕ" to "g", "җ" to "zh", "ҙ" to "dz", "қ" to "q", "ҝ" to "k", "ҟ" to "k", "ҡ" to "k", "ң" to "ng", "ҥ" to "ng", "ҧ" to "p", "ҩ" to "o", "ҫ" to "s", "ҭ" to "t", "ү" to "u", "ұ" to "u", "ҳ" to "kh", "ҵ" to "ts", "ҷ" to "ch", "ҹ" to "ch", "һ" to "h", "ҽ" to "ch", "ҿ" to "ch", "ќ" to "ḱ", "ө" to "ö" ) private val RUSSIAN_ROMAJI_MAP: Map = mapOf( "ого" to "ovo", "Ого" to "Ovo", "его" to "evo", "Его" to "Evo" ) private val UKRAINIAN_ROMAJI_MAP: Map = mapOf( "Г" to "H", "г" to "h", "Ґ" to "G", "ґ" to "g", "Є" to "Ye", "є" to "ye", "І" to "I", "і" to "i", "Ї" to "Yi", "ї" to "yi" ) private val SERBIAN_ROMAJI_MAP: Map = mapOf( "Ж" to "Ž", "Љ" to "Lj", "Њ" to "Nj", "Ц" to "C", "Ч" to "Č", "Џ" to "Dž", "Ш" to "Š", "Х" to "H", "ж" to "ž", "љ" to "lj", "њ" to "nj", "ц" to "c", "ч" to "č", "џ" to "dž", "ш" to "š", "х" to "h" ) private val BULGARIAN_ROMAJI_MAP: Map = mapOf( "Ж" to "Zh", "Ц" to "Ts", "Ч" to "Ch", "Ш" to "Sh", "Щ" to "Sht", "Ъ" to "A", "Ь" to "Y", "Ю" to "Yu", "Я" to "Ya", "ж" to "zh", "ц" to "ts", "ч" to "ch", "ш" to "sh", "щ" to "sht", "ъ" to "a", "ь" to "y", "ю" to "yu", "я" to "ya" ) private val BELARUSIAN_ROMAJI_MAP: Map = mapOf( "Г" to "H", "г" to "h", "Ў" to "W", "ў" to "w" ) private val KYRGYZ_ROMAJI_MAP: Map = mapOf( "Ү" to "Ü", "ү" to "ü", "Ы" to "Y", "ы" to "y" ) private val MACEDONIAN_ROMAJI_MAP: Map = mapOf( "Ѓ" to "Gj", "Ѕ" to "Dz", "И" to "I", "Ј" to "J", "Љ" to "Lj", "Њ" to "Nj", "Ќ" to "Kj", "Џ" to "Dž", "Ч" to "Č", "Ш" to "Sh", "Ж" to "Zh", "Ц" to "C", "Х" to "H", "ѓ" to "gj", "ѕ" to "dz", "и" to "i", "ј" to "j", "љ" to "lj", "њ" to "nj", "ќ" to "kj", "џ" to "dž", "ч" to "č", "ш" to "sh", "ж" to "zh", "ц" to "c", "х" to "h" ) private val RUSSIAN_CYRILLIC_LETTERS = setOf( "А", "Б", "В", "Г", "Д", "Е", "Ё", "Ж", "З", "И", "Й", "К", "Л", "М", "Н", "О", "П", "Р", "С", "Т", "У", "Ф", "Х", "Ц", "Ч", "Ш", "Щ", "Ъ", "Ы", "Ь", "Э", "Ю", "Я", "а", "б", "в", "г", "д", "е", "ё", "ж", "з", "и", "й", "к", "л", "м", "н", "о", "п", "р", "с", "т", "у", "ф", "х", "ц", "ч", "ш", "щ", "ъ", "ы", "ь", "э", "ю", "я" ) private val UKRAINIAN_CYRILLIC_LETTERS = setOf( "А", "Б", "В", "Г", "Ґ", "Д", "Е", "Є", "Ж", "З", "И", "І", "Ї", "Й", "К", "Л", "М", "Н", "О", "П", "Р", "С", "Т", "У", "Ф", "Х", "Ц", "Ч", "Ш", "Щ", "Ь", "Ю", "Я", "а", "б", "в", "г", "ґ", "д", "е", "є", "ж", "з", "и", "і", "ї", "й", "к", "л", "м", "н", "о", "п", "р", "с", "т", "у", "ф", "х", "ц", "ч", "ш", "щ", "ь", "ю", "я" ) private val SERBIAN_CYRILLIC_LETTERS = setOf( "А", "Б", "В", "Г", "Д", "Ђ", "Е", "Ж", "З", "И", "Ј", "К", "Л", "Љ", "М", "Н", "Њ", "О", "П", "Р", "С", "Т", "Ћ", "У", "Ф", "Х", "Ц", "Ч", "Џ", "Ш", "а", "б", "в", "г", "д", "ђ", "е", "ж", "з", "и", "ј", "к", "л", "љ", "м", "н", "њ", "о", "п", "р", "с", "т", "ћ", "у", "ф", "х", "ц", "ч", "џ", "ш" ) private val BULGARIAN_CYRILLIC_LETTERS = setOf( "А", "Б", "В", "Г", "Д", "Е", "Ж", "З", "И", "Й", "К", "Л", "М", "Н", "О", "П", "Р", "С", "Т", "У", "Ф", "Х", "Ц", "Ч", "Ш", "Щ", "Ъ", "Ь", "Ю", "Я", "а", "б", "в", "г", "д", "е", "ж", "з", "и", "й", "к", "л", "м", "н", "о", "п", "р", "с", "т", "у", "ф", "х", "ц", "ч", "ш", "щ", "ъ", "ь", "ю", "я" ) private val BELARUSIAN_CYRILLIC_LETTERS = setOf( "А", "Б", "В", "Г", "Д", "Е", "Ё", "Ж", "З", "І", "Й", "К", "Л", "М", "Н", "О", "П", "Р", "С", "Т", "У", "Ў", "Ф", "Х", "Ц", "Ч", "Ш", "Ь", "Ю", "Я", "Ы", "Э", "а", "б", "в", "г", "д", "е", "ё", "ж", "з", "і", "й", "к", "л", "м", "н", "о", "п", "р", "с", "т", "у", "ў", "ф", "х", "ц", "ч", "ш", "ь", "ю", "я", "ы", "э" ) private val KYRGYZ_CYRILLIC_LETTERS = setOf( "А", "Б", "В", "Г", "Д", "Е", "Ё", "Ж", "З", "И", "Й", "К", "Л", "М", "Н", "Ң", "О", "Ө", "П", "Р", "С", "Т", "У", "Ү", "Ф", "Х", "Ц", "Ч", "Ш", "Щ", "Ъ", "Ы", "Ь", "Э", "Ю", "Я", "а", "б", "в", "г", "д", "е", "ё", "ж", "з", "и", "й", "к", "л", "м", "н", "ң", "о", "ө", "п", "р", "с", "т", "у", "ү", "ф", "х", "ц", "ч", "ш", "щ", "ъ", "ы", "ь", "э", "ю", "я" ) private val MACEDONIAN_CYRILLIC_LETTERS = setOf( "А", "Б", "В", "Г", "Д", "Ѓ", "Е", "Ж", "З", "Ѕ", "И", "Ј", "К", "Л", "Љ", "М", "Н", "Њ", "О", "П", "Р", "С", "Т", "Ќ", "У", "Ф", "Х", "Ц", "Ч", "Џ", "Ш", "а", "б", "в", "г", "д", "ѓ", "е", "ж", "з", "ѕ", "и", "ј", "к", "л", "љ", "м", "н", "њ", "о", "п", "р", "с", "т", "ќ", "у", "ф", "х", "ц", "ч", "џ", "ш" ) private val UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS = setOf( "Ґ", "ґ", "Є", "є", "І", "і", "Ї", "ї" ) private val SERBIAN_SPECIFIC_CYRILLIC_LETTERS = setOf( "Ђ", "ђ", "Ј", "ј", "Љ", "љ", "Њ", "њ", "Ћ", "ћ", "Џ", "џ" ) private val BELARUSIAN_SPECIFIC_CYRILLIC_LETTERS = setOf( "Ў", "ў", "І", "і" ) private val KYRGYZ_SPECIFIC_CYRILLIC_LETTERS = setOf( "Ң", "ң", "Ө", "ө", "Ү", "ү" ) private val MACEDONIAN_SPECIFIC_CYRILLIC_LETTERS = setOf( "Ѓ", "ѓ", "Ѕ", "ѕ", "Ќ", "ќ" ) // Lazy initialized Tokenizer private val kuromojiTokenizer: Tokenizer by lazy { Tokenizer() } private val HEX_ENTITY_REGEX = "&#x([0-9a-fA-F]+);".toRegex() private val DEC_ENTITY_REGEX = "&#(\\d+);".toRegex() private fun decodeHtmlEntities(text: String): String = text .replace(HEX_ENTITY_REGEX) { match -> match.groupValues[1].toIntOrNull(16) ?.takeIf { it in 0..0x10FFFF } ?.let { String(Character.toChars(it)) } ?: match.value } .replace(DEC_ENTITY_REGEX) { match -> match.groupValues[1].toIntOrNull() ?.takeIf { it in 0..0x10FFFF } ?.let { String(Character.toChars(it)) } ?: match.value } .replace("'", "'") .replace(""", "\"") .replace("<", "<") .replace(">", ">") .replace(" ", " ") .replace("&", "&") fun parseLyrics(lyrics: String): List { // Unescape JSON string if needed val unescapedLyrics = lyrics .trim() .removePrefix("\"") .removeSuffix("\"") .replace("\\\\", "\\") .replace("\\n", "\n") .replace("\\r", "\r") .replace("\\t", "\t") // Decode HTML entities (e.g. ' -> ', & -> &) val decodedLyrics = decodeHtmlEntities(unescapedLyrics) val lines = decodedLyrics.lines() .filter { it.isNotBlank() && !it.trim().startsWith("[offset:") } // Check if this is rich sync format (contains patterns) val isRichSync = lines.any { line -> RICH_SYNC_LINE_REGEX.matches(line.trim()) && RICH_SYNC_WORD_REGEX.containsMatchIn(line) } return if (isRichSync) { parseRichSyncLyrics(lines) } else { parseStandardLyrics(lines) } } /** * Parse rich sync lyrics format: [MM:SS.mm] word word ... * This format provides word-by-word timing for karaoke-style highlighting */ private fun parseRichSyncLyrics(lines: List): List { val result = mutableListOf() lines.forEachIndexed { index, line -> val matchResult = RICH_SYNC_LINE_REGEX.matchEntire(line.trim()) if (matchResult != null) { val minutes = matchResult.groupValues[1].toLongOrNull() ?: 0L val seconds = matchResult.groupValues[2].toLongOrNull() ?: 0L val centiseconds = matchResult.groupValues[3].toLongOrNull() ?: 0L // Convert to milliseconds val millisPart = if (matchResult.groupValues[3].length == 3) centiseconds else centiseconds * 10 val lineTimeMs = minutes * DateUtils.MINUTE_IN_MILLIS + seconds * DateUtils.SECOND_IN_MILLIS + millisPart var content = matchResult.groupValues[4].trimStart() // Parse agent marker {agent:v1} val agentMatch = AGENT_REGEX.find(content) val agent = agentMatch?.groupValues?.get(1) if (agentMatch != null) { content = content.replaceFirst(AGENT_REGEX, "") } // Parse background marker {bg} val isBackground = BACKGROUND_REGEX.containsMatchIn(content) if (isBackground) { content = content.replaceFirst(BACKGROUND_REGEX, "") } // Parse word-level timestamps from content val wordTimings = parseRichSyncWords(content, index, lines) // Extract plain text (remove all tags) val plainText = content.replace(Regex("<\\d{1,2}:\\d{2}\\.\\d{2,3}>\\s*"), "").trim() if (plainText.isNotBlank()) { result.add(LyricsEntry(lineTimeMs, plainText, wordTimings, agent = agent, isBackground = isBackground)) } } } return result.sorted() } /** * Parse word timestamps from rich sync content * Format: word word ... */ private fun parseRichSyncWords(content: String, currentIndex: Int, allLines: List): List? { val wordMatches = RICH_SYNC_WORD_REGEX.findAll(content).toList() if (wordMatches.isEmpty()) return null val wordTimings = mutableListOf() wordMatches.forEachIndexed { index, match -> val minutes = match.groupValues[1].toLongOrNull() ?: 0L val seconds = match.groupValues[2].toLongOrNull() ?: 0L val fraction = match.groupValues[3].toLongOrNull() ?: 0L // Convert to seconds (Double) val fractionPart = if (match.groupValues[3].length == 3) fraction / 1000.0 else fraction / 100.0 val startTimeSeconds = minutes * 60.0 + seconds + fractionPart val wordText = match.groupValues[4].trim() // Calculate end time: use next word's start time, or estimate from next line val endTimeSeconds = if (index < wordMatches.size - 1) { val nextMatch = wordMatches[index + 1] val nextMinutes = nextMatch.groupValues[1].toLongOrNull() ?: 0L val nextSeconds = nextMatch.groupValues[2].toLongOrNull() ?: 0L val nextFraction = nextMatch.groupValues[3].toLongOrNull() ?: 0L val nextFractionPart = if (nextMatch.groupValues[3].length == 3) nextFraction / 1000.0 else nextFraction / 100.0 nextMinutes * 60.0 + nextSeconds + nextFractionPart } else { // For last word, try to get next line's start time or add a default duration val nextLineTime = getNextLineStartTime(currentIndex, allLines) nextLineTime ?: (startTimeSeconds + 0.5) // Default 500ms duration for last word } if (wordText.isNotBlank()) { wordTimings.add(WordTimestamp(wordText, startTimeSeconds, endTimeSeconds)) } } return if (wordTimings.isNotEmpty()) wordTimings else null } /** * Get the start time of the next line for calculating the last word's end time */ private fun getNextLineStartTime(currentIndex: Int, allLines: List): Double? { if (currentIndex + 1 >= allLines.size) return null val nextLine = allLines[currentIndex + 1].trim() val matchResult = RICH_SYNC_LINE_REGEX.matchEntire(nextLine) ?: return null val minutes = matchResult.groupValues[1].toLongOrNull() ?: return null val seconds = matchResult.groupValues[2].toLongOrNull() ?: return null val fraction = matchResult.groupValues[3].toLongOrNull() ?: 0L val fractionPart = if (matchResult.groupValues[3].length == 3) fraction / 1000.0 else fraction / 100.0 return minutes * 60.0 + seconds + fractionPart } /** * Parse standard synced lyrics format: [MM:SS.mm] text */ private fun parseStandardLyrics(lines: List): List { val result = mutableListOf() var i = 0 while (i < lines.size) { val line = lines[i] if (!line.trim().startsWith("<") || !line.trim().endsWith(">")) { val entries = parseLine(line, null) if (entries != null) { val wordTimestamps = if (i + 1 < lines.size) { val nextLine = lines[i + 1] if (nextLine.trim().startsWith("<") && nextLine.trim().endsWith(">")) { parseWordTimestamps(nextLine.trim().removeSurrounding("<", ">")) } else null } else null if (wordTimestamps != null) { result.addAll(entries.map { entry -> LyricsEntry(entry.time, entry.text, wordTimestamps, agent = entry.agent, isBackground = entry.isBackground) }) } else { result.addAll(entries) } } } i++ } return result.sorted() } private fun parseWordTimestamps(data: String): List? { if (data.isBlank()) return null return try { data.split("|").mapNotNull { wordData -> val parts = wordData.split(":") if (parts.size == 3) { WordTimestamp( text = parts[0], startTime = parts[1].toDouble(), endTime = parts[2].toDouble() ) } else null } } catch (e: Exception) { null } } private fun parseLine(line: String, words: List? = null): List? { if (line.isEmpty()) { return null } val matchResult = LINE_REGEX.matchEntire(line.trim()) ?: return null val times = matchResult.groupValues[1] var text = matchResult.groupValues[3] val timeMatchResults = TIME_REGEX.findAll(times) // Parse agent marker {agent:v1} val agentMatch = AGENT_REGEX.find(text) val agent = agentMatch?.groupValues?.get(1) if (agentMatch != null) { text = text.replaceFirst(AGENT_REGEX, "") } // Parse background marker {bg} val isBackground = BACKGROUND_REGEX.containsMatchIn(text) if (isBackground) { text = text.replaceFirst(BACKGROUND_REGEX, "") } 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, words, agent = agent, isBackground = isBackground) }.toList() } fun findCurrentLineIndex( lines: List, position: Long, ): Int { for (index in lines.indices) { if (lines[index].time >= position + 300L) { return index - 1 } } return lines.lastIndex } // TODO: Will be useful if we let the user pick the language, useless for now /* enum class CyrillicLanguage { RUSSIAN, UKRAINIAN, SERBIAN, BULGARIAN, BELARUSIAN, KYRGYZ, MACEDONIAN } */ fun katakanaToRomaji(katakana: String?): String { if (katakana.isNullOrEmpty()) return "" val romajiBuilder = StringBuilder(katakana.length) var i = 0 val n = katakana.length while (i < n) { var consumed = false if (i + 1 < n) { val twoCharCandidate = katakana.substring(i, i + 2) val mappedTwoChar = KANA_ROMAJI_MAP[twoCharCandidate] if (mappedTwoChar != null) { romajiBuilder.append(mappedTwoChar) i += 2 consumed = true } } if (!consumed) { val oneCharCandidate = katakana[i].toString() val mappedOneChar = KANA_ROMAJI_MAP[oneCharCandidate] if (mappedOneChar != null) { romajiBuilder.append(mappedOneChar) } else { romajiBuilder.append(oneCharCandidate) } i += 1 } } return romajiBuilder.toString().lowercase() } suspend fun romanizeJapanese(text: String): String = withContext(Dispatchers.Default) { val tokens = kuromojiTokenizer.tokenize(text) val romanizedTokens = tokens.mapIndexed { index, token -> val currentReading = if (token.reading.isNullOrEmpty() || token.reading == "*") { token.surface } else { token.reading } val nextTokenReading = if (index + 1 < tokens.size) { tokens[index + 1].reading?.takeIf { it.isNotEmpty() && it != "*" } ?: tokens[index + 1].surface } else { null } katakanaToRomaji(currentReading, nextTokenReading) } romanizedTokens.joinToString(" ") } fun katakanaToRomaji(katakana: String?, nextKatakana: String? = null): String { if (katakana.isNullOrEmpty()) return "" val romajiBuilder = StringBuilder(katakana.length) var i = 0 val n = katakana.length while (i < n) { var consumed = false if (i + 1 < n) { val twoCharCandidate = katakana.substring(i, i + 2) val mappedTwoChar = KANA_ROMAJI_MAP[twoCharCandidate] if (mappedTwoChar != null) { romajiBuilder.append(mappedTwoChar) i += 2 consumed = true } } if (!consumed && katakana[i] == 'ッ') { val nextCharToDouble = nextKatakana?.getOrNull(0) if (nextCharToDouble != null) { val nextCharRomaji = KANA_ROMAJI_MAP[nextCharToDouble.toString()]?.getOrNull(0)?.toString() ?: nextCharToDouble.toString() romajiBuilder.append(nextCharRomaji.lowercase().trim()) } i += 1 consumed = true } if (!consumed) { val oneCharCandidate = katakana[i].toString() val mappedOneChar = KANA_ROMAJI_MAP[oneCharCandidate] if (mappedOneChar != null) { romajiBuilder.append(mappedOneChar) } else { romajiBuilder.append(oneCharCandidate) } i += 1 } } return romajiBuilder.toString().lowercase() } suspend fun romanizeKorean(text: String): String = withContext(Dispatchers.Default) { val romajaBuilder = StringBuilder() var prevFinal: String? = null for (i in text.indices) { val char = text[i] if (char in '\uAC00'..'\uD7A3') { val syllableIndex = char.code - 0xAC00 val choIndex = syllableIndex / (21 * 28) val jungIndex = (syllableIndex % (21 * 28)) / 28 val jongIndex = syllableIndex % 28 val choChar = (0x1100 + choIndex).toChar().toString() val jungChar = (0x1161 + jungIndex).toChar().toString() val jongChar = if (jongIndex == 0) null else (0x11A7 + jongIndex).toChar().toString() if (prevFinal != null) { val contextKey = prevFinal + choChar val jong = HANGUL_ROMAJA_MAP["jong"]?.get(contextKey) ?: HANGUL_ROMAJA_MAP["jong"]?.get(prevFinal) ?: prevFinal romajaBuilder.append(jong) } val cho = HANGUL_ROMAJA_MAP["cho"]?.get(choChar) ?: choChar val jung = HANGUL_ROMAJA_MAP["jung"]?.get(jungChar) ?: jungChar romajaBuilder.append(cho).append(jung) prevFinal = jongChar } else { if (prevFinal != null) { val jong = HANGUL_ROMAJA_MAP["jong"]?.get(prevFinal) ?: prevFinal romajaBuilder.append(jong) prevFinal = null } romajaBuilder.append(char) } } if (prevFinal != null) { val jong = HANGUL_ROMAJA_MAP["jong"]?.get(prevFinal) ?: prevFinal romajaBuilder.append(jong) } romajaBuilder.toString() } suspend fun romanizeChinese(text: String): String = withContext(Dispatchers.Default) { if (text.isEmpty()) return@withContext "" val builder = StringBuilder(text.length * 2) for (ch in text) { if (ch in '\u4E00'..'\u9FFF') { val py = Pinyin.toPinyin(ch).lowercase(Locale.getDefault()) builder.append(py).append(' ') } else { builder.append(ch) } } // Remove whitespaces before ASCII and CJK punctuations builder.toString() .replace(Regex("\\s+([,.!?;:])"), "$1") .replace(Regex("\\s+([,。!?;:、()《》〈〉【】『』「」])"), "$1") .trim() } suspend fun romanizeCyrillic(text: String): String? = withContext(Dispatchers.Default) { if (text.isEmpty()) return@withContext null val cyrillicChars = text.filter { it in '\u0400'..'\u04FF' } if (cyrillicChars.isEmpty() || (cyrillicChars.length == 1 && (cyrillicChars[0] == 'е' || cyrillicChars[0] == 'Е'))) { return@withContext null } when { isRussian(text) -> romanizeRussianInternal(text) isUkrainian(text) -> romanizeUkrainianInternal(text) isSerbian(text) -> romanizeSerbianInternal(text) isBulgarian(text) -> romanizeBulgarianInternal(text) isBelarusian(text) -> romanizeBelarusianInternal(text) isKyrgyz(text) -> romanizeKyrgyzInternal(text) isMacedonian(text) -> romanizeMacedonianInternal(text) else -> null } } private fun romanizeRussianInternal(text: String): String { val romajiBuilder = StringBuilder(text.length) val words = text.split("((?<=\\s|[.,!?;])|(?=\\s|[.,!?;]))".toRegex()) .filter { it.isNotEmpty() } words.forEachIndexed { _, word -> if (word.matches("[.,!?;]".toRegex()) || word.isBlank()) { romajiBuilder.append(word) } else { var charIndex = 0 while (charIndex < word.length) { var consumed = false // Check for 3-character sequences if (charIndex + 2 < word.length) { val threeCharCandidate = word.substring(charIndex, charIndex + 3) if (RUSSIAN_ROMAJI_MAP.containsKey(threeCharCandidate)) { romajiBuilder.append(RUSSIAN_ROMAJI_MAP[threeCharCandidate]) charIndex += 3 consumed = true } } if (!consumed) { val charStr = word[charIndex].toString() // Special case for 'е' or 'Е' at the start of a word if ((charStr == "е" || charStr == "Е") && (charIndex == 0 || word[charIndex - 1].isWhitespace())) { romajiBuilder.append(if (charStr == "е") "ye" else "Ye") } else { // Apply general Cyrillic mapping (Russian is no different so there's no need to apply a russian map) val romanizedChar = GENERAL_CYRILLIC_ROMAJI_MAP[charStr] ?: charStr romajiBuilder.append(romanizedChar) } charIndex += 1 } } } } return romajiBuilder.toString() } private fun romanizeUkrainianInternal(text: String): String { val romajiBuilder = StringBuilder(text.length) val words = text.split("((?<=\\s|[.,!?;])|(?=\\s|[.,!?;]))".toRegex()) .filter { it.isNotEmpty() } words.forEachIndexed { _, word -> if (word.matches("[.,!?;]".toRegex()) || word.isBlank()) { romajiBuilder.append(word) } else { var charIndex = 0 while (charIndex < word.length) { val charStr = word[charIndex].toString() var processed = false if (charIndex > 0 && word[charIndex - 1].isLetter() && !isCyrillicVowel(word[charIndex - 1])) { // Check if the current character is Ю or Я and is preceded by a consonant if (charStr == "Ю") { romajiBuilder.append("Iu") processed = true } else if (charStr == "ю") { romajiBuilder.append("iu") processed = true } else if (charStr == "Я") { romajiBuilder.append("Ia") processed = true } else if (charStr == "я") { romajiBuilder.append("ia") processed = true } } if (!processed) { romajiBuilder.append(UKRAINIAN_ROMAJI_MAP[charStr] ?: GENERAL_CYRILLIC_ROMAJI_MAP[charStr] ?: charStr) } charIndex++ } } } return romajiBuilder.toString() } private fun romanizeSerbianInternal(text: String): String { val romajiBuilder = StringBuilder(text.length) val words = text.split("((?<=\\s|[.,!?;])|(?=\\s|[.,!?;]))".toRegex()) .filter { it.isNotEmpty() } words.forEachIndexed { _, word -> if (word.matches("[.,!?;]".toRegex()) || word.isBlank()) { romajiBuilder.append(word) } else { var charIndex = 0 while (charIndex < word.length) { val charStr = word[charIndex].toString() val romanizedChar = SERBIAN_ROMAJI_MAP[charStr] ?: GENERAL_CYRILLIC_ROMAJI_MAP[charStr] ?: charStr romajiBuilder.append(romanizedChar) charIndex++ } } } return romajiBuilder.toString() } private fun romanizeBulgarianInternal(text: String): String { val romajiBuilder = StringBuilder(text.length) val words = text.split("((?<=\\s|[.,!?;])|(?=\\s|[.,!?;]))".toRegex()) .filter { it.isNotEmpty() } words.forEachIndexed { _, word -> if (word.matches("[.,!?;]".toRegex()) || word.isBlank()) { romajiBuilder.append(word) } else { var charIndex = 0 while (charIndex < word.length) { val charStr = word[charIndex].toString() val romanizedChar = BULGARIAN_ROMAJI_MAP[charStr] ?: GENERAL_CYRILLIC_ROMAJI_MAP[charStr] ?: charStr romajiBuilder.append(romanizedChar) charIndex++ } } } return romajiBuilder.toString() } private fun romanizeBelarusianInternal(text: String): String { val romajiBuilder = StringBuilder(text.length) val words = text.split("((?<=\\s|[.,!?;])|(?=\\s|[.,!?;]))".toRegex()) .filter { it.isNotEmpty() } words.forEach { word -> if (word.matches("[.,!?;]".toRegex()) || word.isBlank()) { romajiBuilder.append(word) } else { var charIndex = 0 while (charIndex < word.length) { val charStr = word[charIndex].toString() // Special case for 'е' or 'Е' at the start of a word if ((charStr == "е" || charStr == "Е") && (charIndex == 0 || word[charIndex - 1].isWhitespace())) { romajiBuilder.append(if (charStr == "е") "ye" else "Ye") } else { // General mapping val romanizedChar = BELARUSIAN_ROMAJI_MAP[charStr] ?: GENERAL_CYRILLIC_ROMAJI_MAP[charStr] ?: charStr romajiBuilder.append(romanizedChar) } charIndex += 1 } } } return romajiBuilder.toString() } private fun romanizeKyrgyzInternal(text: String): String { val romajiBuilder = StringBuilder(text.length) val words = text.split("((?<=\\s|[.,!?;])|(?=\\s|[.,!?;]))".toRegex()) .filter { it.isNotEmpty() } words.forEachIndexed { _, word -> if (word.matches("[.,!?;]".toRegex()) || word.isBlank()) { romajiBuilder.append(word) } else { var charIndex = 0 while (charIndex < word.length) { val charStr = word[charIndex].toString() val romanizedChar = KYRGYZ_ROMAJI_MAP[charStr] ?: GENERAL_CYRILLIC_ROMAJI_MAP[charStr] ?: charStr romajiBuilder.append(romanizedChar) charIndex++ } } } return romajiBuilder.toString() } private fun romanizeMacedonianInternal(text: String): String { val romajiBuilder = StringBuilder(text.length) val words = text.split("((?<=\\s|[.,!?;])|(?=\\s|[.,!?;]))".toRegex()) .filter { it.isNotEmpty() } words.forEachIndexed { _, word -> if (word.matches("[.,!?;]".toRegex()) || word.isBlank()) { romajiBuilder.append(word) } else { var charIndex = 0 while (charIndex < word.length) { val charStr = word[charIndex].toString() val romanizedChar = MACEDONIAN_ROMAJI_MAP[charStr] ?: GENERAL_CYRILLIC_ROMAJI_MAP[charStr] ?: charStr romajiBuilder.append(romanizedChar) charIndex++ } } } return romajiBuilder.toString() } // TODO: This function might be used later if we let the user choose the language manually /** private suspend fun romanizeCyrillicWithLanguage(text: String, language: CyrillicLanguage): String = withContext(Dispatchers.Default) { if (text.isEmpty()) return@withContext "" val detectedLanguage = language ?: when { isRussian(text) -> CyrillicLanguage.RUSSIAN isUkrainian(text) -> CyrillicLanguage.UKRAINIAN isSerbian(text) -> CyrillicLanguage.SERBIAN isBelarusian(text) -> CyrillicLanguage.BELARUSIAN isKyrgyz(text) -> CyrillicLanguage.KYRGYZ isMacedonian(text) -> CyrillicLanguage.MACEDONIAN else -> return@withContext text } val languageMap: Map = when (detectedLanguage) { CyrillicLanguage.RUSSIAN -> RUSSIAN_ROMAJI_MAP CyrillicLanguage.UKRAINIAN -> UKRAINIAN_ROMAJI_MAP CyrillicLanguage.SERBIAN -> SERBIAN_ROMAJI_MAP CyrillicLanguage.BELARUSIAN -> BELARUSIAN_ROMAJI_MAP CyrillicLanguage.KYRGYZ -> KYRGYZ_ROMAJI_MAP CyrillicLanguage.MACEDONIAN -> MACEDONIAN_ROMAJI_MAP // else -> emptyMap() } val languageLetters = when (language) { CyrillicLanguage.RUSSIAN -> RUSSIAN_CYRILLIC_LETTERS CyrillicLanguage.UKRAINIAN -> UKRAINIAN_CYRILLIC_LETTERS CyrillicLanguage.SERBIAN -> SERBIAN_CYRILLIC_LETTERS CyrillicLanguage.BELARUSIAN -> BELARUSIAN_CYRILLIC_LETTERS CyrillicLanguage.KYRGYZ -> KYRGYZ_CYRILLIC_LETTERS CyrillicLanguage.MACEDONIAN -> MACEDONIAN_CYRILLIC_LETTERS else -> GENERAL_CYRILLIC_ROMAJI_MAP.keys } val romajiBuilder = StringBuilder(text.length) val words = text.split("((?<=\\s|[.,!?;])|(?=\\s|[.,!?;]))".toRegex()) .filter { it.isNotEmpty() } words.forEachIndexed { _, word -> if (word.matches("[.,!?;]".toRegex()) || word.isBlank()) { // Preserve punctuation or spaces as is romajiBuilder.append(word) } else { // Process word var charIndex = 0 while (charIndex < word.length) { var consumed = false // Check for 3-character sequences (language-specific, e.g., Russian) if (detectedLanguage == CyrillicLanguage.RUSSIAN && charIndex + 2 < word.length) { val threeCharCandidate = word.substring(charIndex, charIndex + 3) if (languageLetters is Set<*> && languageLetters.containsAll(threeCharCandidate.toList().map { it.toString() })) { val mappedThreeChar = languageMap[threeCharCandidate] if (mappedThreeChar != null) { romajiBuilder.append(mappedThreeChar) charIndex += 3 consumed = true } } } if (!consumed) { val charStr = word[charIndex].toString() val isSpecificLanguageChar = languageLetters is Set<*> && languageLetters.contains(charStr) val isGeneralCyrillicChar = GENERAL_CYRILLIC_ROMAJI_MAP.containsKey(charStr) if (isSpecificLanguageChar || isGeneralCyrillicChar) { if (detectedLanguage == CyrillicLanguage.RUSSIAN && (charStr == "е" || charStr == "Е") && charIndex == 0 && (charIndex == 0 || word[charIndex-1].isWhitespace())) { romajiBuilder.append(if (charStr == "е") "ye" else "Ye") } else { val romanizedChar = languageMap[charStr] ?: GENERAL_CYRILLIC_ROMAJI_MAP[charStr] if (romanizedChar != null) { romajiBuilder.append(romanizedChar) } else { romajiBuilder.append(charStr) } } } else { romajiBuilder.append(charStr) } charIndex += 1 } } } } romajiBuilder.toString() } */ fun isRussian(text: String): Boolean { return text.any { char -> RUSSIAN_CYRILLIC_LETTERS.contains(char.toString()) } && text.all { char -> val charStr = char.toString() RUSSIAN_CYRILLIC_LETTERS.contains(charStr) || !charStr.matches("[\\u0400-\\u04FF]".toRegex()) } } fun isUkrainian(text: String): Boolean { return text.any { char -> UKRAINIAN_CYRILLIC_LETTERS.contains(char.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) } && text.all { char -> UKRAINIAN_CYRILLIC_LETTERS.contains(char.toString()) || UKRAINIAN_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) || !char.toString().matches("[\\u0400-\\u04FF]".toRegex()) } } fun isSerbian(text: String): Boolean { return text.any { char -> SERBIAN_CYRILLIC_LETTERS.contains(char.toString()) || SERBIAN_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) } && text.all { char -> SERBIAN_CYRILLIC_LETTERS.contains(char.toString()) || SERBIAN_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) || !char.toString().matches("[\\u0400-\\u04FF]".toRegex()) } } fun isBulgarian(text: String): Boolean { return text.any { char -> BULGARIAN_CYRILLIC_LETTERS.contains(char.toString()) // Bulgarian doesn't have any language specific letters } && text.all { char -> BULGARIAN_CYRILLIC_LETTERS.contains(char.toString()) || !char.toString().matches("[\\u0400-\\u04FF]".toRegex()) } } fun isBelarusian(text: String): Boolean { return text.any { char -> BELARUSIAN_CYRILLIC_LETTERS.contains(char.toString()) || BELARUSIAN_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) } && text.all { char -> BELARUSIAN_CYRILLIC_LETTERS.contains(char.toString()) || BELARUSIAN_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) || !char.toString().matches("[\\u0400-\\u04FF]".toRegex()) } } fun isKyrgyz(text: String): Boolean { return text.any { char -> KYRGYZ_CYRILLIC_LETTERS.contains(char.toString()) || KYRGYZ_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) } && text.all { char -> KYRGYZ_CYRILLIC_LETTERS.contains(char.toString()) || KYRGYZ_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) || !char.toString().matches("[\\u0400-\\u04FF]".toRegex()) } } fun isMacedonian(text: String): Boolean { return text.any { char -> MACEDONIAN_CYRILLIC_LETTERS.contains(char.toString()) || MACEDONIAN_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) } && text.all { char -> MACEDONIAN_CYRILLIC_LETTERS.contains(char.toString()) || MACEDONIAN_SPECIFIC_CYRILLIC_LETTERS.contains(char.toString()) || !char.toString().matches("[\\u0400-\\u04FF]".toRegex()) } } fun isJapanese(text: String): Boolean { return text.any { char -> (char in '\u3040'..'\u309F') || // Hiragana (char in '\u30A0'..'\u30FF') || // Katakana (char in '\u4E00'..'\u9FFF') // CJK Unified Ideographs } } fun isKorean(text: String): Boolean { return text.any { char -> (char in '\uAC00'..'\uD7A3') // Hangul Syllables } } fun isChinese(text: String): Boolean { if (text.isEmpty()) return false val cjkCharCount = text.count { char -> char in '\u4E00'..'\u9FFF' } val hiraganaKatakanaCount = text.count { char -> (char in '\u3040'..'\u309F') || (char in '\u30A0'..'\u30FF') } return cjkCharCount > 0 && (hiraganaKatakanaCount.toDouble() / text.length.toDouble()) < 0.1 } fun isHindi(text: String): Boolean { return text.any { char -> char in '\u0900'..'\u097F' } } suspend fun romanizeHindi(text: String): String = withContext(Dispatchers.Default) { val sb = StringBuilder(text.length) var i = 0 while (i < text.length) { var consumed = false // Check for 2-character sequences (e.g. char + nukta) if (i + 1 < text.length) { val twoCharCandidate = text.substring(i, i + 2) val mappedTwoChar = DEVANAGARI_ROMAJI_MAP[twoCharCandidate] if (mappedTwoChar != null) { sb.append(mappedTwoChar) i += 2 consumed = true } } if (!consumed) { val charStr = text[i].toString() sb.append(DEVANAGARI_ROMAJI_MAP[charStr] ?: charStr) i += 1 } } sb.toString() } fun isPunjabi(text: String): Boolean { return text.any { char -> char in '\u0A00'..'\u0A7F' } } suspend fun romanizePunjabi(text: String): String = withContext(Dispatchers.Default) { val sb = StringBuilder(text.length) var i = 0 while (i < text.length) { val char = text[i] var consumed = false // Check for Adhak (Gemination) if (char == '\u0A71') { // Double next consonant if possible if (i + 1 < text.length) { val nextCharStr = text[i+1].toString() val nextMapped = GURMUKHI_ROMAJI_MAP[nextCharStr] if (nextMapped != null && nextMapped.isNotEmpty()) { sb.append(nextMapped[0]) } } i++ continue } // Check for 2-character sequences (e.g. char + nukta) if (i + 1 < text.length) { val twoCharCandidate = text.substring(i, i + 2) val mappedTwoChar = GURMUKHI_ROMAJI_MAP[twoCharCandidate] if (mappedTwoChar != null) { sb.append(mappedTwoChar) i += 2 consumed = true } } if (!consumed) { val str = char.toString() sb.append(GURMUKHI_ROMAJI_MAP[str] ?: str) i++ } } sb.toString() } private fun isCyrillicVowel(char: Char): Boolean { return "АаЕеЄєИиІіЇїОоУуЮюЯяЫыЭэ".contains(char) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/SimpMusicLyricsProvider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.content.Context import com.metrolist.music.constants.EnableSimpMusicKey import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.simpmusic.SimpMusicLyrics object SimpMusicLyricsProvider : LyricsProvider { override val name = "SimpMusic" override fun isEnabled(context: Context): Boolean = context.dataStore[EnableSimpMusicKey] ?: true override suspend fun getLyrics( id: String, title: String, artist: String, duration: Int, album: String?, ): Result = SimpMusicLyrics.getLyrics(id, duration) override suspend fun getAllLyrics( id: String, title: String, artist: String, duration: Int, album: String?, callback: (String) -> Unit, ) { SimpMusicLyrics.getAllLyrics(id, duration, callback) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/YouTubeLyricsProvider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.content.Context import com.metrolist.innertube.YouTube import com.metrolist.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, album: String?, ): 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") } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/lyrics/YouTubeSubtitleLyricsProvider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.lyrics import android.content.Context import com.metrolist.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, album: String?, ): Result = YouTube.transcript(id) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/models/ItemsPage.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.models import com.metrolist.innertube.models.YTItem data class ItemsPage( val items: List, val continuation: String?, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/models/MediaMetadata.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.models import androidx.compose.runtime.Immutable import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.WatchEndpoint.WatchEndpointMusicSupportedConfigs.WatchEndpointMusicConfig.Companion.MUSIC_VIDEO_TYPE_ATV import com.metrolist.music.db.entities.Song import com.metrolist.music.db.entities.SongEntity import com.metrolist.music.ui.utils.resize import java.io.Serializable import java.time.LocalDateTime @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 musicVideoType: String? = null, val explicit: Boolean = false, val liked: Boolean = false, val likedDate: LocalDateTime? = null, val inLibrary: LocalDateTime? = null, val libraryAddToken: String? = null, val libraryRemoveToken: String? = null, val suggestedBy: String? = null, val isEpisode: Boolean = false, val uploadEntityId: String? = null, ) : Serializable { val isVideoSong: Boolean get() = musicVideoType != null && musicVideoType != MUSIC_VIDEO_TYPE_ATV 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, explicit = explicit, liked = liked, likedDate = likedDate, inLibrary = inLibrary, libraryAddToken = libraryAddToken, libraryRemoveToken = libraryRemoveToken, isVideo = isVideoSong, isEpisode = isEpisode, uploadEntityId = uploadEntityId ) } fun Song.toMediaMetadata() = MediaMetadata( id = song.id, title = song.title, artists = orderedArtists.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(), ) }, explicit = song.explicit, // Use a non-ATV type if isVideo is true to indicate it's a video song musicVideoType = if (song.isVideo) "MUSIC_VIDEO_TYPE_OMV" else null, suggestedBy = null, isEpisode = song.isEpisode, ) 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, musicVideoType = musicVideoType, libraryAddToken = libraryAddToken, libraryRemoveToken = libraryRemoveToken, suggestedBy = null, isEpisode = isEpisode, uploadEntityId = uploadEntityId ) fun EpisodeItem.toMediaMetadata() = MediaMetadata( id = id, title = title, artists = listOfNotNull(author).map { MediaMetadata.Artist( id = it.id, name = it.name, ) }, duration = duration ?: -1, thumbnailUrl = thumbnail.resize(544, 544), album = podcast?.let { MediaMetadata.Album( id = it.id, title = it.name, ) }, explicit = explicit, suggestedBy = null, isEpisode = true, libraryAddToken = libraryAddToken, libraryRemoveToken = libraryRemoveToken, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/models/PersistPlayerState.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, val timestamp: Long = System.currentTimeMillis() ) : Serializable ================================================ FILE: app/src/main/kotlin/com/metrolist/music/models/PersistQueue.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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() } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/models/SimilarRecommendation.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.models import com.metrolist.innertube.models.YTItem import com.metrolist.music.db.entities.LocalItem data class SimilarRecommendation( val title: LocalItem, val items: List, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/DownloadUtil.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.innertube.YouTube import com.metrolist.music.constants.AudioQuality import com.metrolist.music.constants.AudioQualityKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.FormatEntity import com.metrolist.music.db.entities.SongEntity import com.metrolist.music.di.DownloadCache import com.metrolist.music.di.PlayerCache import com.metrolist.music.utils.YTPlayerUtils import com.metrolist.music.utils.enumPreference import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Runnable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import java.time.LocalDateTime 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val downloads = MutableStateFlow>(emptyMap()) private val dataSourceFactory = ResolvingDataSource.Factory( CacheDataSource .Factory() .setCache(playerCache) .setUpstreamDataSourceFactory( OkHttpDataSource.Factory( OkHttpClient.Builder() .proxy(YouTube.proxy) .proxyAuthenticator { _, response -> YouTube.proxyAuth?.let { auth -> response.request.newBuilder() .header("Proxy-Authorization", auth) .build() } ?: response.request } .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 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, perceptualLoudnessDb = playbackData.audioConfig?.perceptualLoudnessDb, playbackUrl = playbackData.playbackTracking?.videostatsPlaybackUrl?.baseUrl ), ) val now = LocalDateTime.now() val existing = getSongByIdBlocking(mediaId)?.song val updatedSong = if (existing != null) { if (existing.dateDownload == null) { existing.copy(dateDownload = now) } else { existing } } else { SongEntity( id = mediaId, title = playbackData.videoDetails?.title ?: "Unknown", duration = playbackData.videoDetails?.lengthSeconds?.toIntOrNull() ?: 0, thumbnailUrl = playbackData.videoDetails?.thumbnail?.thumbnails?.lastOrNull()?.url, dateDownload = now, isDownloaded = false ) } upsert(updatedSong) } val streamUrl = playbackData.streamUrl.let { "${it}&range=0-${format.contentLength ?: 10000000}" } songUrlCache[mediaId] = streamUrl to playbackData.streamExpiresInSeconds * 1000L dataSpec.withUri(streamUrl.toUri()) } val downloadNotificationHelper = DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID) @OptIn(DelicateCoroutinesApi::class) val downloadManager: DownloadManager = DownloadManager( context, databaseProvider, downloadCache, dataSourceFactory, Executor(Runnable::run) ).apply { maxParallelDownloads = 3 addListener( object : DownloadManager.Listener { override fun onDownloadChanged( downloadManager: DownloadManager, download: Download, finalException: Exception?, ) { downloads.update { map -> map.toMutableMap().apply { set(download.request.id, download) } } scope.launch { when (download.state) { Download.STATE_COMPLETED -> { database.updateDownloadedInfo(download.request.id, true, LocalDateTime.now()) } Download.STATE_FAILED, Download.STATE_STOPPED, Download.STATE_REMOVING -> { database.updateDownloadedInfo(download.request.id, false, null) } else -> { } } } } } ) } init { val result = mutableMapOf() val cursor = downloadManager.downloadIndex.getDownloads() while (cursor.moveToNext()) { result[cursor.download.request.id] = cursor.download } downloads.value = result } fun getDownload(songId: String): Flow = downloads.map { it[songId] } fun release() { scope.cancel() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/ExoDownloadService.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.music.R import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint class ExoDownloadService : DownloadService( NOTIFICATION_ID, 1000L, CHANNEL_ID, R.string.downloading, 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" } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/MediaLibrarySessionCallback.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.C 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.MediaSession.MediaItemsWithStartPosition import androidx.media3.session.SessionCommand import androidx.media3.session.SessionError import androidx.media3.session.SessionResult import coil3.imageLoader import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.filterExplicit import com.metrolist.innertube.models.filterVideoSongs import com.metrolist.music.R import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.MediaSessionConstants import com.metrolist.music.constants.SongSortType import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.extensions.toggleRepeatMode import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.utils.reportException 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.GlobalScope import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch import kotlinx.coroutines.plus import javax.inject.Inject import com.metrolist.music.constants.AndroidAutoSectionsOrderKey import com.metrolist.music.constants.AndroidAutoYouTubePlaylistsKey import com.metrolist.music.ui.screens.settings.AndroidAutoSection import com.metrolist.music.ui.screens.settings.deserializeSections import com.metrolist.music.ui.screens.settings.serializeSections class MediaLibrarySessionCallback @Inject constructor( @ApplicationContext val context: Context, val database: MusicDatabase, val downloadUtil: DownloadUtil, ) : MediaLibrarySession.Callback { private val scope = CoroutineScope(Dispatchers.Main) + Job() lateinit var service: MusicService var toggleLike: () -> Unit = {} var toggleStartRadio: () -> Unit = {} var toggleLibrary: () -> Unit = {} var addToTargetPlaylist: () -> 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.CommandToggleStartRadio) .add(MediaSessionConstants.CommandToggleLibrary) .add(MediaSessionConstants.CommandToggleShuffle) .add(MediaSessionConstants.CommandToggleRepeatMode) .add(MediaSessionConstants.CommandAddToTargetPlaylist) .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_START_RADIO -> toggleStartRadio() MediaSessionConstants.ACTION_TOGGLE_LIBRARY -> toggleLibrary() MediaSessionConstants.ACTION_TOGGLE_SHUFFLE -> session.player.shuffleModeEnabled = !session.player.shuffleModeEnabled MediaSessionConstants.ACTION_TOGGLE_REPEAT_MODE -> session.player.toggleRepeatMode() MediaSessionConstants.ACTION_ADD_TO_TARGET_PLAYLIST -> addToTargetPlaylist() } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } @Deprecated("Deprecated in MediaLibrarySession.Callback") override fun onPlaybackResumption( mediaSession: MediaSession, controller: MediaSession.ControllerInfo ): ListenableFuture { return SettableFuture.create() } 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 -> { val sectionsRaw = context.dataStore.get( AndroidAutoSectionsOrderKey, serializeSections(AndroidAutoSection.values().map { it to true }) ) val sections = deserializeSections(sectionsRaw) sections .filter { (_, enabled) -> enabled } .ifEmpty { listOf(AndroidAutoSection.LIKED to true) } .map { (section, _) -> when (section) { AndroidAutoSection.LIKED -> browsableMediaItem( "${MusicService.PLAYLIST}/${PlaylistEntity.LIKED_PLAYLIST_ID}", context.getString(R.string.liked_songs), null, drawableUri(R.drawable.favorite), MediaMetadata.MEDIA_TYPE_PLAYLIST, ) AndroidAutoSection.SONGS -> browsableMediaItem( MusicService.SONG, context.getString(R.string.songs), null, drawableUri(R.drawable.music_note), MediaMetadata.MEDIA_TYPE_PLAYLIST, ) AndroidAutoSection.ARTISTS -> browsableMediaItem( MusicService.ARTIST, context.getString(R.string.artists), null, drawableUri(R.drawable.artist), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, ) AndroidAutoSection.ALBUMS -> browsableMediaItem( MusicService.ALBUM, context.getString(R.string.albums), null, drawableUri(R.drawable.album), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, ) AndroidAutoSection.PLAYLISTS -> 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.artistsByCreateDateAsc().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.albumsByCreateDateAsc().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 val showYoutubePlaylists = context.dataStore.get(AndroidAutoYouTubePlaylistsKey, false) // Build local playlists immediately val localItems = 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, ) } // Fetch YouTube playlists asynchronously if enabled if (showYoutubePlaylists) { GlobalScope.launch(Dispatchers.IO) { try { val youtubePlaylists = YouTube.home().getOrNull()?.sections ?.flatMap { it.items } ?.filterIsInstance() ?.take(10) ?: emptyList() if (youtubePlaylists.isNotEmpty()) { session.notifyChildrenChanged( MusicService.PLAYLIST, localItems.size + youtubePlaylists.size, null ) } } catch (e: Exception) { reportException(e) } } } localItems } else -> when { parentId.startsWith("${MusicService.ARTIST}/") -> database.artistSongsByCreateDateAsc(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}/") -> { val playlistId = parentId.removePrefix("${MusicService.PLAYLIST}/") val songs = when (playlistId) { 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() // Add shuffle item at the top listOf( MediaItem.Builder() .setMediaId("$parentId/${MusicService.SHUFFLE_ACTION}") .setMediaMetadata( MediaMetadata.Builder() .setTitle(context.getString(R.string.shuffle)) .setArtworkUri(drawableUri(R.drawable.shuffle)) .setIsPlayable(true) .setIsBrowsable(false) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .build() ).build() ) + songs.map { it.toMediaItem(parentId) } } parentId.startsWith("${MusicService.YOUTUBE_PLAYLIST}/") -> { val playlistId = parentId.removePrefix("${MusicService.YOUTUBE_PLAYLIST}/") try { val songs = YouTube.playlist(playlistId).getOrNull()?.songs ?.take(100) ?.filterExplicit(context.dataStore.get(HideExplicitKey, false)) ?.filterVideoSongs(context.dataStore.get(HideVideoSongsKey, false)) ?: emptyList() // Add shuffle item at the top listOf( MediaItem.Builder() .setMediaId("$parentId/${MusicService.SHUFFLE_ACTION}") .setMediaMetadata( MediaMetadata.Builder() .setTitle(context.getString(R.string.shuffle)) .setArtworkUri(drawableUri(R.drawable.shuffle)) .setIsPlayable(true) .setIsBrowsable(false) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .build() ).build() ) + songs.map { songItem -> MediaItem.Builder() .setMediaId("$parentId/${songItem.id}") .setMediaMetadata( MediaMetadata.Builder() .setTitle(songItem.title) .setSubtitle(songItem.artists.joinToString(", ") { it.name }) .setArtist(songItem.artists.joinToString(", ") { it.name }) .setArtworkUri(songItem.thumbnail.toUri()) .setIsPlayable(true) .setIsBrowsable(false) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .build() ) .build() } } catch (e: Exception) { reportException(e) emptyList() } } 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 onSearch( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String, params: MediaLibraryService.LibraryParams? ): ListenableFuture> { session.notifySearchResultChanged(browser, query, 1, params) return Futures.immediateFuture(LibraryResult.ofVoid()) } override fun onGetSearchResult( session: MediaLibrarySession, browser: MediaSession.ControllerInfo, query: String, page: Int, pageSize: Int, params: MediaLibraryService.LibraryParams? ): ListenableFuture>> { return scope.future(Dispatchers.IO) { if (query.isEmpty()) { return@future LibraryResult.ofItemList(emptyList(), params) } try { val searchResults = mutableListOf() val localSongs = database.allSongs().first().filter { song -> song.song.title.contains(query, ignoreCase = true) || song.artists.any { it.name.contains(query, ignoreCase = true) } || song.album?.title?.contains(query, ignoreCase = true) == true } val artistSongs = database.searchArtists(query).first().flatMap { artist -> database.artistSongsByCreateDateAsc(artist.id).first() } val albumSongs = database.searchAlbums(query).first().flatMap { album -> database.albumSongs(album.id).first() } val playlistSongs = database.searchPlaylists(query).first().flatMap { playlist -> database.playlistSongs(playlist.id).first().map { it.song } } val allLocalSongs = (localSongs + artistSongs + albumSongs + playlistSongs) .distinctBy { it.id } allLocalSongs.forEach { song -> searchResults.add(song.toMediaItem( path = "${MusicService.SEARCH}/$query", isPlayable = true, isBrowsable = true )) } try { val onlineResults = YouTube.search(query, YouTube.SearchFilter.FILTER_SONG) .getOrNull() ?.items ?.filterIsInstance() ?.filterExplicit(context.dataStore.get(HideExplicitKey, false)) ?.filterVideoSongs(context.dataStore.get(HideVideoSongsKey, false)) ?.filter { onlineSong -> !allLocalSongs.any { localSong -> localSong.id == onlineSong.id || (localSong.song.title.equals(onlineSong.title, ignoreCase = true) && localSong.artists.any { artist -> onlineSong.artists.any { it.name.equals(artist.name, ignoreCase = true) } }) } } ?: emptyList() onlineResults.forEach { songItem -> try { database.query { insert(songItem.toMediaMetadata()) } } catch (e: Exception) { } searchResults.add( MediaItem.Builder() .setMediaId("${MusicService.SEARCH}/$query/${songItem.id}") .setMediaMetadata( MediaMetadata.Builder() .setTitle(songItem.title) .setSubtitle(songItem.artists.joinToString(", ") { it.name }) .setArtist(songItem.artists.joinToString(", ") { it.name }) .setArtworkUri(songItem.thumbnail.toUri()) .setIsPlayable(true) .setIsBrowsable(true) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .build() ) .build() ) } } catch (e: Exception) { reportException(e) } LibraryResult.ofItemList(searchResults, params) } catch (e: Exception) { reportException(e) LibraryResult.ofItemList(emptyList(), params) } } } override fun onSetMediaItems( mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList, startIndex: Int, startPositionMs: Long, ): ListenableFuture = scope.future { val defaultResult = 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() 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() 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 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() // Check if this is a shuffle action if (songId == MusicService.SHUFFLE_ACTION) { MediaItemsWithStartPosition( songs.shuffled().map { it.toMediaItem() }, 0, C.TIME_UNSET ) } else { MediaItemsWithStartPosition( songs.map { it.toMediaItem() }, songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, startPositionMs ) } } MusicService.YOUTUBE_PLAYLIST -> { val songId = path.getOrNull(2) ?: return@future defaultResult val playlistId = path.getOrNull(1) ?: return@future defaultResult val songs = try { YouTube.playlist(playlistId).getOrNull()?.songs?.map { it.toMediaItem() } ?: emptyList() } catch (e: Exception) { reportException(e) return@future defaultResult } // Check if this is a shuffle action if (songId == MusicService.SHUFFLE_ACTION) { MediaItemsWithStartPosition( songs.shuffled(), 0, C.TIME_UNSET ) } else { MediaItemsWithStartPosition( songs, songs.indexOfFirst { it.mediaId.endsWith(songId) }.takeIf { it != -1 } ?: 0, C.TIME_UNSET ) } } MusicService.SEARCH -> { val songId = path.getOrNull(2) ?: return@future defaultResult val searchQuery = path.getOrNull(1) ?: return@future defaultResult val searchResults = mutableListOf() val localSongs = database.allSongs().first().filter { song -> song.song.title.contains(searchQuery, ignoreCase = true) || song.artists.any { it.name.contains(searchQuery, ignoreCase = true) } || song.album?.title?.contains(searchQuery, ignoreCase = true) == true } val artistSongs = database.searchArtists(searchQuery).first().flatMap { artist -> database.artistSongsByCreateDateAsc(artist.id).first() } val albumSongs = database.searchAlbums(searchQuery).first().flatMap { album -> database.albumSongs(album.id).first() } val playlistSongs = database.searchPlaylists(searchQuery).first().flatMap { playlist -> database.playlistSongs(playlist.id).first().map { it.song } } val allLocalSongs = (localSongs + artistSongs + albumSongs + playlistSongs) .distinctBy { it.id } searchResults.addAll(allLocalSongs) try { val onlineResults = YouTube.search(searchQuery, YouTube.SearchFilter.FILTER_SONG) .getOrNull() ?.items ?.filterIsInstance() ?.filterExplicit(context.dataStore.get(HideExplicitKey, false)) ?.filterVideoSongs(context.dataStore.get(HideVideoSongsKey, false)) ?.filter { onlineSong -> !allLocalSongs.any { localSong -> localSong.id == onlineSong.id || (localSong.song.title.equals(onlineSong.title, ignoreCase = true) && localSong.artists.any { artist -> onlineSong.artists.any { it.name.equals(artist.name, ignoreCase = true) } }) } } ?: emptyList() onlineResults.forEach { songItem -> try { database.query { insert(songItem.toMediaMetadata()) } database.song(songItem.id).first()?.let { newSong -> searchResults.add(newSong) } } catch (e: Exception) { } } } catch (e: Exception) { reportException(e) } if (searchResults.isEmpty()) { return@future defaultResult } val targetIndex = searchResults.indexOfFirst { it.id == songId } MediaItemsWithStartPosition( searchResults.map { it.toMediaItem() }, if (targetIndex >= 0) targetIndex else 0, C.TIME_UNSET ) } 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, isPlayable: Boolean = true, isBrowsable: Boolean = false): MediaItem { val artworkUri = song.thumbnailUrl?.let { val snapshot = context.imageLoader.diskCache?.openSnapshot(it) if (snapshot != null) { snapshot.use { snapshot -> snapshot.data.toFile().toUri() } } else { it.toUri() } } return MediaItem .Builder() .setMediaId("$path/$id") .setMediaMetadata( MediaMetadata .Builder() .setTitle(song.title) .setSubtitle(artists.joinToString { it.name }) .setArtist(artists.joinToString { it.name }) .setArtworkUri(artworkUri) .setIsPlayable(isPlayable) .setIsBrowsable(isBrowsable) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .build(), ).build() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/MetrolistCacheEvictor.kt ================================================ package com.metrolist.music.playback import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.CacheEvictor import androidx.media3.datasource.cache.CacheSpan import com.metrolist.music.db.MusicDatabase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class MetrolistCacheEvictor( private val wrappedEvictor: CacheEvictor, private val database: MusicDatabase ) : CacheEvictor { private val scope = CoroutineScope(Dispatchers.IO) private var cache: Cache? = null override fun requiresCacheSpanTouches(): Boolean { return wrappedEvictor.requiresCacheSpanTouches() } override fun onCacheInitialized() { wrappedEvictor.onCacheInitialized() } override fun onStartFile(cache: Cache, key: String, position: Long, length: Long) { this.cache = cache wrappedEvictor.onStartFile(cache, key, position, length) } override fun onSpanAdded(cache: Cache, span: CacheSpan) { this.cache = cache wrappedEvictor.onSpanAdded(cache, span) checkSpanAndSync(cache, span) } override fun onSpanRemoved(cache: Cache, span: CacheSpan) { this.cache = cache wrappedEvictor.onSpanRemoved(cache, span) checkSpanAndSync(cache, span) } override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) { this.cache = cache wrappedEvictor.onSpanTouched(cache, oldSpan, newSpan) } private fun checkSpanAndSync(cache: Cache, span: CacheSpan) { val mediaId = span.key scope.launch { try { val entity = database.getSongById(mediaId) if (entity != null) { val length = if (entity.song.duration > 0) { androidx.media3.datasource.cache.ContentMetadata.getContentLength( cache.getContentMetadata(mediaId) ) } else { -1L } val contentLength = androidx.media3.datasource.cache.ContentMetadata.getContentLength( cache.getContentMetadata(mediaId) ) if (contentLength != androidx.media3.common.C.LENGTH_UNSET.toLong()) { val cachedSpans = cache.getCachedSpans(mediaId) var cachedBytes = 0L for (s in cachedSpans) { cachedBytes += s.length } val isCached = cachedBytes > 0 && cachedBytes >= (contentLength * 0.99).toLong() if (entity.song.isCached != isCached) { database.updateCachedInfo(mediaId, isCached) } } } } catch (e: Exception) { e.printStackTrace() } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ @file:Suppress("DEPRECATION") package com.metrolist.music.playback import android.app.ForegroundServiceStartNotAllowedException import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.ServiceInfo import android.database.SQLException import android.media.AudioDeviceCallback import android.media.AudioDeviceInfo import android.media.AudioFocusRequest import android.media.audiofx.AudioEffect import android.media.audiofx.LoudnessEnhancer import android.media.AudioManager import android.net.ConnectivityManager import android.os.Binder import android.os.Build import android.os.Handler import android.os.Looper import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.datastore.preferences.core.edit import androidx.media3.common.audio.SonicAudioProcessor import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackParameters 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.util.UnstableApi 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.DataSource import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.ResolvingDataSource 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.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer 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.google.common.util.concurrent.MoreExecutors import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.innertube.YouTube import com.metrolist.lastfm.LastFM import com.metrolist.music.constants.AndroidAutoTargetPlaylistKey import com.metrolist.music.constants.AudioNormalizationKey import com.metrolist.music.constants.AudioOffload import com.metrolist.music.constants.AudioQualityKey import com.metrolist.music.constants.AutoDownloadOnLikeKey import com.metrolist.music.constants.AutoLoadMoreKey import com.metrolist.music.constants.AutoSkipNextOnErrorKey import com.metrolist.music.constants.CrossfadeDurationKey import com.metrolist.music.constants.CrossfadeEnabledKey import com.metrolist.music.constants.CrossfadeGaplessKey import com.metrolist.music.constants.DisableLoadMoreWhenRepeatAllKey import com.metrolist.music.constants.DiscordActivityNameKey import com.metrolist.music.constants.DiscordActivityTypeKey import com.metrolist.music.constants.DiscordAdvancedModeKey import com.metrolist.music.constants.DiscordAvatarKey import com.metrolist.music.constants.DiscordButton1TextKey import com.metrolist.music.constants.DiscordButton1VisibleKey import com.metrolist.music.constants.DiscordButton2TextKey import com.metrolist.music.constants.DiscordButton2VisibleKey import com.metrolist.music.constants.DiscordStatusKey import com.metrolist.music.constants.DiscordTokenKey import com.metrolist.music.constants.DiscordUseDetailsKey import com.metrolist.music.constants.EnableDiscordRPCKey import com.metrolist.music.constants.EnableLastFMScrobblingKey import com.metrolist.music.constants.EnableSongCacheKey import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.HistoryDuration import com.metrolist.music.constants.LastFMUseNowPlaying import com.metrolist.music.constants.MediaSessionConstants import com.metrolist.music.constants.MediaSessionConstants.CommandAddToTargetPlaylist import com.metrolist.music.constants.MediaSessionConstants.CommandToggleLike import com.metrolist.music.constants.MediaSessionConstants.CommandToggleRepeatMode import com.metrolist.music.constants.MediaSessionConstants.CommandToggleShuffle import com.metrolist.music.constants.MediaSessionConstants.CommandToggleStartRadio import com.metrolist.music.constants.PauseListenHistoryKey import com.metrolist.music.constants.PauseOnMute import com.metrolist.music.constants.PersistentQueueKey import com.metrolist.music.constants.PersistentShuffleAcrossQueuesKey import com.metrolist.music.constants.PlayerVolumeKey import com.metrolist.music.constants.PreventDuplicateTracksInQueueKey import com.metrolist.music.constants.RememberShuffleAndRepeatKey import com.metrolist.music.constants.RepeatModeKey import com.metrolist.music.constants.ResumeOnBluetoothConnectKey import com.metrolist.music.constants.ScrobbleDelayPercentKey import com.metrolist.music.constants.ScrobbleDelaySecondsKey import com.metrolist.music.constants.ScrobbleMinSongDurationKey import com.metrolist.music.constants.ShowLyricsKey import com.metrolist.music.constants.ShuffleModeKey import com.metrolist.music.constants.ShufflePlaylistFirstKey import com.metrolist.music.constants.SimilarContent import com.metrolist.music.constants.SkipSilenceInstantKey import com.metrolist.music.constants.SkipSilenceKey import com.metrolist.music.db.entities.Event import com.metrolist.music.db.entities.FormatEntity import com.metrolist.music.db.entities.LyricsEntity import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.RelatedSongMap import com.metrolist.music.db.entities.Song import com.metrolist.music.db.MusicDatabase import com.metrolist.music.di.DownloadCache import com.metrolist.music.di.PlayerCache import com.metrolist.music.eq.audio.CustomEqualizerAudioProcessor import com.metrolist.music.eq.data.EQProfileRepository import com.metrolist.music.eq.EqualizerService import com.metrolist.music.extensions.collect import com.metrolist.music.extensions.collectLatest import com.metrolist.music.extensions.currentMetadata import com.metrolist.music.extensions.findNextMediaItemById import com.metrolist.music.extensions.mediaItems import com.metrolist.music.extensions.metadata import com.metrolist.music.extensions.setOffloadEnabled import com.metrolist.music.extensions.SilentHandler import com.metrolist.music.extensions.toEnum import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.extensions.toPersistQueue import com.metrolist.music.extensions.toQueue import com.metrolist.music.lyrics.LyricsHelper import com.metrolist.music.MainActivity import com.metrolist.music.models.PersistPlayerState import com.metrolist.music.models.PersistQueue import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.alarm.MusicAlarmScheduler import com.metrolist.music.playback.alarm.MusicAlarmStore import com.metrolist.music.playback.audio.SilenceDetectorAudioProcessor import com.metrolist.music.playback.queues.EmptyQueue import com.metrolist.music.playback.queues.filterExplicit import com.metrolist.music.playback.queues.filterVideoSongs import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.playback.queues.Queue import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.R import com.metrolist.music.utils.CoilBitmapLoader import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.DiscordRPC import com.metrolist.music.utils.get import com.metrolist.music.utils.NetworkConnectivityObserver import com.metrolist.music.utils.reportException import com.metrolist.music.utils.ScrobbleManager import com.metrolist.music.utils.SyncUtils import com.metrolist.music.utils.YTPlayerUtils import com.metrolist.music.widget.MetrolistWidgetManager import com.metrolist.music.widget.MusicWidgetReceiver import dagger.hilt.android.AndroidEntryPoint import java.io.ObjectInputStream import java.io.ObjectOutputStream import java.time.LocalDateTime import javax.inject.Inject import kotlin.coroutines.coroutineContext import kotlin.random.Random import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.asStateFlow 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.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.isActive import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import timber.log.Timber private const val INSTANT_SILENCE_SKIP_STEP_MS = 15_000L private const val INSTANT_SILENCE_SKIP_SETTLE_MS = 350L @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @androidx.annotation.OptIn(UnstableApi::class) @AndroidEntryPoint class MusicService : MediaLibraryService(), Player.Listener, PlaybackStatsListener.Callback { @Inject lateinit var database: MusicDatabase @Inject lateinit var lyricsHelper: LyricsHelper @Inject lateinit var syncUtils: SyncUtils @Inject lateinit var mediaLibrarySessionCallback: MediaLibrarySessionCallback @Inject lateinit var equalizerService: EqualizerService @Inject lateinit var eqProfileRepository: EQProfileRepository @Inject lateinit var widgetManager: MetrolistWidgetManager @Inject lateinit var listenTogetherManager: com.metrolist.music.listentogether.ListenTogetherManager 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 reentrantFocusGain = false private var wasPlayingBeforeVolumeMute = false private var isPausedByVolumeMute = false private var crossfadeEnabled = false private var crossfadeDuration = 5000f private var crossfadeGapless = true private var crossfadeTriggerJob: Job? = null private val secondaryPlayerListener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { Timber.tag(TAG).e(error, "Secondary player error") secondaryPlayer?.stop() secondaryPlayer?.clearMediaItems() secondaryPlayer = null } } private var scope = CoroutineScope(Dispatchers.Main) + Job() private val binder = MusicBinder() inner class MusicBinder : Binder() { val service: MusicService get() = this@MusicService } private lateinit var connectivityManager: ConnectivityManager lateinit var connectivityObserver: NetworkConnectivityObserver val waitingForNetworkConnection = MutableStateFlow(false) private val isNetworkConnected = MutableStateFlow(false) private lateinit var audioQuality: com.metrolist.music.constants.AudioQuality 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) } lateinit var playerVolume: MutableStateFlow val isMuted = MutableStateFlow(false) private val sleepTimerVolumeMultiplier = MutableStateFlow(1f) private val audioFocusVolumeMultiplier = MutableStateFlow(1f) fun toggleMute() { val newMutedState = !isMuted.value isMuted.value = newMutedState applyEffectiveVolume() } fun setMuted(muted: Boolean) { isMuted.value = muted applyEffectiveVolume() } private fun calculateEffectiveVolume( volume: Float = playerVolume.value, muted: Boolean = isMuted.value, sleepTimerMultiplier: Float = sleepTimerVolumeMultiplier.value, focusMultiplier: Float = audioFocusVolumeMultiplier.value, ): Float { if (muted) return 0f return (volume * sleepTimerMultiplier * focusMultiplier).coerceIn(0f, 1f) } private fun applyEffectiveVolume() { if (!::player.isInitialized || isCrossfading) return player.volume = calculateEffectiveVolume() } lateinit var sleepTimer: SleepTimer @Inject @PlayerCache lateinit var playerCache: SimpleCache @Inject @DownloadCache lateinit var downloadCache: SimpleCache lateinit var player: ExoPlayer private set private var secondaryPlayer: ExoPlayer? = null private var fadingPlayer: ExoPlayer? = null private var isCrossfading = false private var crossfadeJob: Job? = null private lateinit var mediaSession: MediaLibrarySession // Tracks if player has been properly initilized private val playerInitialized = MutableStateFlow(false) val isPlayerReady: kotlinx.coroutines.flow.StateFlow = playerInitialized.asStateFlow() // Expose active player flow for UI/Connection updates private val _playerFlow = MutableStateFlow(null) val playerFlow = _playerFlow.asStateFlow() private val playerSilenceProcessors = HashMap() private val instantSilenceSkipEnabled = MutableStateFlow(false) private var isAudioEffectSessionOpened = false private var loudnessEnhancer: LoudnessEnhancer? = null private var discordRpc: DiscordRPC? = null private var lastPlaybackSpeed = 1.0f private var discordUpdateJob: kotlinx.coroutines.Job? = null private var scrobbleManager: ScrobbleManager? = null val automixItems = MutableStateFlow>(emptyList()) // Tracks the original queue size to distinguish original items from auto-added ones private var originalQueueSize: Int = 0 private var consecutivePlaybackErr = 0 private var retryJob: Job? = null private var retryCount = 0 private var silenceSkipJob: Job? = null // URL cache for stream URLs - class-level so it can be invalidated on errors private val songUrlCache = HashMap>() // Flag to bypass cache when quality changes - forces fresh stream fetch private val bypassCacheForQualityChange = mutableSetOf() // Enhanced error tracking for strict retry management private var currentMediaIdRetryCount = mutableMapOf() private val MAX_RETRY_PER_SONG = 3 private val RETRY_DELAY_MS = 1000L // Track failed songs to prevent infinite retry loops private val recentlyFailedSongs = mutableSetOf() private var failedSongsClearJob: Job? = null // Google Cast support var castConnectionHandler: CastConnectionHandler? = null private set private val screenStateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { Intent.ACTION_SCREEN_OFF -> { if (!player.isPlaying) { scope.launch(Dispatchers.IO) { discordRpc?.closeRPC() } } } Intent.ACTION_SCREEN_ON -> { if (player.isPlaying) { scope.launch { currentSong.value?.let { song -> updateDiscordRPC(song) } } } } } } } private val audioDeviceCallback = object : AudioDeviceCallback() { override fun onAudioDevicesAdded(addedDevices: Array?) { super.onAudioDevicesAdded(addedDevices) val hasBluetooth = addedDevices?.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } == true if (hasBluetooth) { if (dataStore.get(ResumeOnBluetoothConnectKey, false)) { if (player.playbackState == Player.STATE_READY && !player.isPlaying) { player.play() } } } } } override fun onCreate() { super.onCreate() isRunning = true // Player rediness reset to false playerInitialized.value = false // 3. Connect the processor to the service // handled in createExoPlayer try { val nm = getSystemService(NotificationManager::class.java) nm?.createNotificationChannel( NotificationChannel( CHANNEL_ID, getString(R.string.music_player), NotificationManager.IMPORTANCE_LOW ) ) val pending = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.music_player)) .setContentText("") .setSmallIcon(R.drawable.small_icon) .setContentIntent(pending) .setOngoing(true) .build() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK ) } else { startForeground(NOTIFICATION_ID, notification) } } catch (e: Exception) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException ) { Timber.tag(TAG).w("Foreground service start not allowed (likely app in background)") } else { Timber.tag(TAG).e(e, "Failed to create foreground notification") reportException(e) } } setMediaNotificationProvider( DefaultMediaNotificationProvider( this, { NOTIFICATION_ID }, CHANNEL_ID, R.string.music_player ) .apply { setSmallIcon(R.drawable.small_icon) }, ) player = createExoPlayer() player.addListener(this@MusicService) sleepTimer = SleepTimer(scope, player) { multiplier -> sleepTimerVolumeMultiplier.value = multiplier } player.addListener(sleepTimer) // Mark player as initialized after successful creation playerInitialized.value = true Timber.tag(TAG).d("Player successfully initialized") // Sync initial cache state scope.launch(Dispatchers.IO) { try { val cachedIds = playerCache.keys.toList() if (cachedIds.isNotEmpty()) { val fullyCachedIds = cachedIds.filter { mediaId -> val contentLength = playerCache.getContentMetadata(mediaId) .get(androidx.media3.datasource.cache.ContentMetadata.KEY_CONTENT_LENGTH, -1L) if (contentLength > 0) { val cachedBytes = playerCache.getCachedSpans(mediaId).sumOf { it.length } cachedBytes >= contentLength * 0.99 } else { false } } if (fullyCachedIds.isNotEmpty()) { val chunkSize = 500 for (i in fullyCachedIds.indices step chunkSize) { val chunk = fullyCachedIds.subList(i, minOf(i + chunkSize, fullyCachedIds.size)) database.updateCachedInfoMany(chunk) } } } } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to sync initial cache state") } } audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager setupAudioFocusRequest() mediaLibrarySessionCallback.apply { toggleLike = ::toggleLike toggleStartRadio = ::toggleStartRadio toggleLibrary = ::toggleLibrary addToTargetPlaylist = ::addToTargetPlaylist } 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) // Restore shuffle mode if remember option is enabled if (dataStore.get(RememberShuffleAndRepeatKey, true)) { player.shuffleModeEnabled = dataStore.get(ShuffleModeKey, false) } // 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) val screenStateFilter = IntentFilter().apply { addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) } registerReceiver(screenStateReceiver, screenStateFilter) audioManager.registerAudioDeviceCallback(audioDeviceCallback, null) audioQuality = dataStore.get(AudioQualityKey).toEnum(com.metrolist.music.constants.AudioQuality.AUTO) playerVolume = MutableStateFlow(dataStore.get(PlayerVolumeKey, 1f).coerceIn(0f, 1f)) // Initialize Google Cast initializeCast() // 4. Watch for EQ profile changes scope.launch { eqProfileRepository.activeProfile.collect { profile -> if (profile != null) { val result = equalizerService.applyProfile(profile) if (result.isSuccess && player.playbackState == Player.STATE_READY && player.isPlaying) { // Instant update: flush buffers and seek slightly to re-process audio // Small seek to force re-buffer through the new EQ settings // Seek to current position effectively resets the pipeline player.seekTo(player.currentPosition) } } else { equalizerService.disable() if (player.playbackState == Player.STATE_READY && player.isPlaying) { player.seekTo(player.currentPosition) } } } } scope.launch { connectivityObserver.networkStatus.collect { isConnected -> isNetworkConnected.value = isConnected if (isConnected && waitingForNetworkConnection.value) { triggerRetry() } // Update Discord RPC when network becomes available if (isConnected && discordRpc != null && player.isPlaying) { val mediaId = player.currentMetadata?.id if (mediaId != null) { database.song(mediaId).first()?.let { song -> updateDiscordRPC(song) } } } } } // Watch for audio quality setting changes var isFirstQualityEmit = true scope.launch { dataStore.data .map { it[AudioQualityKey]?.let { value -> com.metrolist.music.constants.AudioQuality.entries.find { it.name == value } } ?: com.metrolist.music.constants.AudioQuality.AUTO } .distinctUntilChanged() .collect { newQuality -> val oldQuality = audioQuality audioQuality = newQuality // Skip reload on first emit (app startup) if (isFirstQualityEmit) { isFirstQualityEmit = false Timber.tag("MusicService").i("QUALITY INIT: $newQuality") return@collect } Timber.tag("MusicService").i("QUALITY CHANGED: $oldQuality -> $newQuality") // Reload current song with new quality val mediaId = player.currentMediaItem?.mediaId ?: return@collect val currentPosition = player.currentPosition val wasPlaying = player.isPlaying val currentIndex = player.currentMediaItemIndex Timber.tag("MusicService").i("RELOADING STREAM: $mediaId at position ${currentPosition}ms") // Clear cached URL to force fresh fetch songUrlCache.remove(mediaId) // CRITICAL: Clear caches synchronously to prevent format parsing errors runBlocking(Dispatchers.IO) { try { playerCache.removeResource(mediaId) downloadCache.removeResource(mediaId) Timber.tag("MusicService").d("Cleared player and download cache for $mediaId") } catch (e: Exception) { Timber.tag("MusicService").e(e, "Failed to clear cache for $mediaId") } } // Set bypass flag so resolver skips cache checks bypassCacheForQualityChange.add(mediaId) Timber.tag("MusicService").d("Set bypass cache flag for $mediaId") // Reload player at same position player.stop() player.seekTo(currentIndex, currentPosition) player.prepare() if (wasPlaying) { player.play() } } } combine( playerVolume, isMuted, sleepTimerVolumeMultiplier, audioFocusVolumeMultiplier, ) { volume, muted, timerMultiplier, focusMultiplier -> calculateEffectiveVolume( volume = volume, muted = muted, sleepTimerMultiplier = timerMultiplier, focusMultiplier = focusMultiplier, ) }.collectLatest(scope) { if (!isCrossfading) { player.volume = it } } playerVolume.debounce(1000).collect(scope) { volume -> dataStore.edit { settings -> settings[PlayerVolumeKey] = volume } } currentSong.debounce(1000).collect(scope) { song -> updateNotification() updateWidgetUI(player.isPlaying) } 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 lyricsWithProvider = lyricsHelper.getLyrics(mediaMetadata) database.query { upsert( LyricsEntity( id = mediaMetadata.id, lyrics = lyricsWithProvider.lyrics, provider = lyricsWithProvider.provider, ), ) } } } dataStore.data .map { (it[SkipSilenceKey] ?: false) to (it[SkipSilenceInstantKey] ?: false) } .distinctUntilChanged() .collectLatest(scope) { (skipSilence, instantSkip) -> player.skipSilenceEnabled = skipSilence secondaryPlayer?.skipSilenceEnabled = skipSilence val enableInstant = skipSilence && instantSkip instantSilenceSkipEnabled.value = enableInstant playerSilenceProcessors.values.forEach { processor -> processor.instantModeEnabled = enableInstant if (!enableInstant) { processor.resetTracking() } } if (!enableInstant) { silenceSkipJob?.cancel() } } combine( currentFormat, dataStore.data .map { it[AudioNormalizationKey] ?: true } .distinctUntilChanged(), ) { format, normalizeAudio -> format to normalizeAudio }.collectLatest(scope) { (format, normalizeAudio) -> setupLoudnessEnhancer() } combine( dataStore.data.map { it[AudioOffload] ?: false }, dataStore.data.map { it[CrossfadeEnabledKey] ?: false } ) { offloadPref, crossfadeEnabled -> // Force disable offload if crossfade is enabled to prevent volume ramp issues if (crossfadeEnabled) false else offloadPref }.distinctUntilChanged() .collectLatest(scope) { useOffload -> player.setOffloadEnabled(useOffload) secondaryPlayer?.setOffloadEnabled(useOffload) } 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 { updateDiscordRPC(it, true) } } } } // Watch all Discord customization preferences dataStore.data .map { listOf( it[DiscordUseDetailsKey], it[DiscordAdvancedModeKey], it[DiscordStatusKey], it[DiscordButton1TextKey], it[DiscordButton1VisibleKey], it[DiscordButton2TextKey], it[DiscordButton2VisibleKey], it[DiscordActivityTypeKey], it[DiscordActivityNameKey] ) } .debounce(300) .distinctUntilChanged() .collect(scope) { if (player.playbackState == Player.STATE_READY) { currentSong.value?.let { song -> updateDiscordRPC(song, true) } } } dataStore.data .map { it[EnableLastFMScrobblingKey] ?: false } .debounce(300) .distinctUntilChanged() .collect(scope) { enabled -> if (enabled && scrobbleManager == null) { val delayPercent = dataStore.get(ScrobbleDelayPercentKey, LastFM.DEFAULT_SCROBBLE_DELAY_PERCENT) val minSongDuration = dataStore.get(ScrobbleMinSongDurationKey, LastFM.DEFAULT_SCROBBLE_MIN_SONG_DURATION) val delaySeconds = dataStore.get(ScrobbleDelaySecondsKey, LastFM.DEFAULT_SCROBBLE_DELAY_SECONDS) scrobbleManager = ScrobbleManager( scope, minSongDuration = minSongDuration, scrobbleDelayPercent = delayPercent, scrobbleDelaySeconds = delaySeconds ) scrobbleManager?.useNowPlaying = dataStore.get(LastFMUseNowPlaying, false) } else if (!enabled && scrobbleManager != null) { scrobbleManager?.destroy() scrobbleManager = null } } dataStore.data .map { it[LastFMUseNowPlaying] ?: false } .distinctUntilChanged() .collectLatest(scope) { scrobbleManager?.useNowPlaying = it } dataStore.data .map { prefs -> Triple( prefs[ScrobbleDelayPercentKey] ?: LastFM.DEFAULT_SCROBBLE_DELAY_PERCENT, prefs[ScrobbleMinSongDurationKey] ?: LastFM.DEFAULT_SCROBBLE_MIN_SONG_DURATION, prefs[ScrobbleDelaySecondsKey] ?: LastFM.DEFAULT_SCROBBLE_DELAY_SECONDS ) } .distinctUntilChanged() .collect(scope) { (delayPercent, minSongDuration, delaySeconds) -> scrobbleManager?.let { it.scrobbleDelayPercent = delayPercent it.minSongDuration = minSongDuration it.scrobbleDelaySeconds = delaySeconds } } combine( dataStore.data.map { prefs -> Triple( prefs[CrossfadeEnabledKey] ?: false, prefs[CrossfadeDurationKey] ?: 5f, prefs[CrossfadeGaplessKey] ?: true ) }, listenTogetherManager.roomState ) { (enabled, duration, gapless), roomState -> // Disable crossfade if user is in a listen together room Triple(enabled && roomState == null, duration, gapless) } .distinctUntilChanged() .collect(scope) { (enabled, duration, gapless) -> crossfadeEnabled = enabled crossfadeDuration = duration * 1000f // Convert to ms crossfadeGapless = gapless } if (dataStore.get(PersistentQueueKey, true)) { val queueFile = filesDir.resolve(PERSISTENT_QUEUE_FILE) if (queueFile.exists()) { runCatching { queueFile.inputStream().use { fis -> ObjectInputStream(fis).use { oos -> oos.readObject() as PersistQueue } } }.onSuccess { queue -> runCatching { // Convert back to proper queue type val restoredQueue = queue.toQueue() // Wait for player initialization before playing scope.launch { playerInitialized.first { it } if (isActive) { playQueue( queue = restoredQueue, playWhenReady = false, ) } } }.onFailure { error -> Timber.tag(TAG).w(error, "Failed to restore persisted queue, clearing data") clearPersistedQueueFiles() } }.onFailure { error -> Timber.tag(TAG).w(error, "Failed to read persisted queue, clearing data") clearPersistedQueueFiles() } } val automixFile = filesDir.resolve(PERSISTENT_AUTOMIX_FILE) if (automixFile.exists()) { runCatching { automixFile.inputStream().use { fis -> ObjectInputStream(fis).use { oos -> oos.readObject() as PersistQueue } } }.onSuccess { queue -> runCatching { automixItems.value = queue.items.map { it.toMediaItem() } }.onFailure { error -> Timber.tag(TAG).w(error, "Failed to restore automix queue, clearing data") clearPersistedQueueFiles() } }.onFailure { error -> Timber.tag(TAG).w(error, "Failed to read automix queue, clearing data") clearPersistedQueueFiles() } } // Restore player state val playerStateFile = filesDir.resolve(PERSISTENT_PLAYER_STATE_FILE) if (playerStateFile.exists()) { runCatching { playerStateFile.inputStream().use { fis -> ObjectInputStream(fis).use { oos -> oos.readObject() as PersistPlayerState } } }.onSuccess { playerState -> // Restore player settings after queue is loaded scope.launch { delay(1000) // Wait for queue to be loaded // Don't restore repeat/shuffle from playerState as they are already set from DataStore (source of truth) // player.repeatMode = playerState.repeatMode // player.shuffleModeEnabled = playerState.shuffleModeEnabled playerVolume.value = playerState.volume // Restore position if it's still valid if (playerState.currentMediaItemIndex < player.mediaItemCount) { player.seekTo(playerState.currentMediaItemIndex, playerState.currentPosition) } } }.onFailure { error -> Timber.tag(TAG).w(error, "Failed to read player state, clearing data") clearPersistedQueueFiles() } } } // Save queue periodically to prevent queue loss from crash or force kill scope.launch { while (isActive) { delay(15.seconds) if (dataStore.get(PersistentQueueKey, true)) { saveQueueToDisk() } // Also save episode position periodically val currentMetadata = player.currentMediaItem?.metadata if (currentMetadata?.isEpisode == true && player.isPlaying && player.currentPosition > 0) { previousEpisodePosition = player.currentPosition saveEpisodePosition(currentMetadata.id, player.currentPosition) } } } // Save queue more frequently when playing to ensure state is preserved scope.launch { while (isActive) { delay(10.seconds) if (dataStore.get(PersistentQueueKey, true) && player.isPlaying) { saveQueueToDisk() } } } } private fun createExoPlayer(): ExoPlayer { val eqProcessor = CustomEqualizerAudioProcessor() equalizerService.addAudioProcessor(eqProcessor) val silenceProcessor = SilenceDetectorAudioProcessor { handleLongSilenceDetected() } // Set initial state runBlocking { val skipSilence = dataStore.get(SkipSilenceKey, false) val instantSkip = dataStore.get(SkipSilenceInstantKey, false) silenceProcessor.instantModeEnabled = skipSilence && instantSkip } val player = ExoPlayer.Builder(this) .setMediaSourceFactory(createMediaSourceFactory()) .setRenderersFactory(createRenderersFactory(eqProcessor, silenceProcessor)) .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) .setDeviceVolumeControlEnabled(true) .build() playerSilenceProcessors[player] = silenceProcessor player.apply { runBlocking { val offload = dataStore.get(AudioOffload, false) val crossfade = dataStore.get(CrossfadeEnabledKey, false) setOffloadEnabled(if (crossfade) false else offload) skipSilenceEnabled = dataStore.get(SkipSilenceKey, false) } addAnalyticsListener(PlaybackStatsListener(false, this@MusicService)) // Cleanup handled manually in onDestroy/release } _playerFlow.value = player return player } 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() } private fun handleAudioFocusChange(focusChange: Int) { when (focusChange) { AudioManager.AUDIOFOCUS_GAIN, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> { hasAudioFocus = true audioFocusVolumeMultiplier.value = 1f if (wasPlayingBeforeAudioFocusLoss && !player.isPlaying && !reentrantFocusGain) { reentrantFocusGain = true scope.launch { delay(300) if (hasAudioFocus && wasPlayingBeforeAudioFocusLoss && !player.isPlaying) { // Don't start local playback if casting if (castConnectionHandler?.isCasting?.value != true) { player.play() } wasPlayingBeforeAudioFocusLoss = false } reentrantFocusGain = false } } applyEffectiveVolume() lastAudioFocusState = focusChange } AudioManager.AUDIOFOCUS_LOSS -> { hasAudioFocus = false audioFocusVolumeMultiplier.value = 1f wasPlayingBeforeAudioFocusLoss = player.isPlaying if (player.isPlaying) { player.pause() } abandonAudioFocus() lastAudioFocusState = focusChange } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { hasAudioFocus = false audioFocusVolumeMultiplier.value = 1f wasPlayingBeforeAudioFocusLoss = player.isPlaying if (player.isPlaying) { player.pause() } lastAudioFocusState = focusChange } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { hasAudioFocus = false audioFocusVolumeMultiplier.value = 0.2f wasPlayingBeforeAudioFocusLoss = player.isPlaying if (player.isPlaying) { applyEffectiveVolume() } lastAudioFocusState = focusChange } AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> { hasAudioFocus = true audioFocusVolumeMultiplier.value = 1f applyEffectiveVolume() lastAudioFocusState = focusChange } } } 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 } private fun abandonAudioFocus() { if (hasAudioFocus) { audioFocusRequest?.let { request -> audioManager.abandonAudioFocusRequest(request) hasAudioFocus = false } } } private fun clearPersistedQueueFiles() { runCatching { filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() } runCatching { filesDir.resolve(PERSISTENT_AUTOMIX_FILE).delete() } runCatching { filesDir.resolve(PERSISTENT_PLAYER_STATE_FILE).delete() } } fun hasAudioFocusForPlayback(): Boolean { return hasAudioFocus } private fun waitOnNetworkError() { if (waitingForNetworkConnection.value) return // Check if we've exceeded max retry attempts if (retryCount >= MAX_RETRY_COUNT) { Timber.tag(TAG).w("Max retry count ($MAX_RETRY_COUNT) reached, stopping playback") stopOnError() retryCount = 0 return } waitingForNetworkConnection.value = true // Start a retry timer with exponential backoff retryJob?.cancel() retryJob = scope.launch { // Exponential backoff: 3s, 6s, 12s, 24s... max 30s val delayMs = minOf(3000L * (1 shl retryCount), 30000L) Timber.tag(TAG).d("Waiting ${delayMs}ms before retry attempt ${retryCount + 1}/$MAX_RETRY_COUNT") delay(delayMs) if (isNetworkConnected.value && waitingForNetworkConnection.value) { retryCount++ triggerRetry() } } } private fun triggerRetry() { waitingForNetworkConnection.value = false retryJob?.cancel() if (player.currentMediaItem != null) { // After 3+ failed retries, try to refresh the stream URL by seeking to current position // This forces ExoPlayer to re-resolve the data source and get a fresh URL if (retryCount > 3) { Timber.tag(TAG).d("Retry count > 3, attempting to refresh stream URL") val currentPosition = player.currentPosition player.seekTo(player.currentMediaItemIndex, currentPosition) } player.prepare() // Don't call play() here - let the player auto-resume via playWhenReady // This avoids stealing audio focus during retry attempts } } 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() // Don't start local playback if casting if (castConnectionHandler?.isCasting?.value != true) { 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.ic_heart else R.drawable.ic_heart_outline) .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(), CommandButton.Builder() .setDisplayName(getString(R.string.start_radio)) .setIconResId(R.drawable.radio) .setSessionCommand(CommandToggleStartRadio) .setEnabled(currentSong.value != null) .build(), CommandButton.Builder() .setDisplayName(getString(R.string.android_auto_target_playlist)) .setIconResId(R.drawable.playlist_add) .setSessionCommand(CommandAddToTargetPlaylist) .setEnabled(currentSong.value != null) .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 { var updatedSong = song.song if (song.song.duration == -1) { updatedSong = updatedSong.copy(duration = duration) } // Update isVideo flag if it's different from the current value if (song.song.isVideo != mediaMetadata.isVideoSong) { updatedSong = updatedSong.copy(isVideo = mediaMetadata.isVideoSong) } if (updatedSong != song.song) { update(updatedSong) } } } 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() // Safety Check : Ensuring player is initilized if (!playerInitialized.value) { Timber.tag(TAG).w("playQueue called before player initialization, queuing request") scope.launch { playerInitialized.first { it } playQueue(queue, playWhenReady) } return } currentQueue = queue queueTitle = null val persistShuffleAcrossQueues = dataStore.get(PersistentShuffleAcrossQueuesKey, false) val previousShuffleEnabled = player.shuffleModeEnabled if (!persistShuffleAcrossQueues) { player.shuffleModeEnabled = false } // Reset original queue size when starting a new queue originalQueueSize = 0 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)) .filterVideoSongs(dataStore.get(HideVideoSongsKey, false)) } if (queue.preloadItem != null && player.playbackState == STATE_IDLE) return@launch if (initialStatus.title != null) { queueTitle = initialStatus.title } if (initialStatus.items.isEmpty()) return@launch // Track original queue size for shuffle playlist first feature originalQueueSize = initialStatus.items.size 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 } // Rebuild shuffle order if shuffle is enabled if (player.shuffleModeEnabled) { val shufflePlaylistFirst = dataStore.get(ShufflePlaylistFirstKey, false) applyShuffleOrder(player.currentMediaItemIndex, player.mediaItemCount, shufflePlaylistFirst) } } } fun startRadioSeamlessly() { // Safety Check: Ensure Player is initilized if (!playerInitialized.value) { Timber.tag(TAG).w("startRadioSeamlessly called before player initialization") return } val currentMediaMetadata = player.currentMetadata ?: return val currentIndex = player.currentMediaItemIndex val currentMediaId = currentMediaMetadata.id scope.launch(SilentHandler) { // Use simple videoId to let YouTube personalize recommendations val radioQueue = YouTubeQueue( endpoint = WatchEndpoint( videoId = currentMediaId ) ) try { val initialStatus = withContext(Dispatchers.IO) { radioQueue.getInitialStatus() .filterExplicit(dataStore.get(HideExplicitKey, false)) .filterVideoSongs(dataStore.get(HideVideoSongsKey, false)) } if (initialStatus.title != null) { queueTitle = initialStatus.title } // Filter radio items to exclude current media item val radioItems = initialStatus.items.filter { item -> item.mediaId != currentMediaId } if (radioItems.isNotEmpty()) { val itemCount = player.mediaItemCount if (itemCount > currentIndex + 1) { player.removeMediaItems(currentIndex + 1, itemCount) } player.addMediaItems(currentIndex + 1, radioItems) if (player.shuffleModeEnabled) { val shufflePlaylistFirst = dataStore.get(ShufflePlaylistFirstKey, false) applyShuffleOrder(player.currentMediaItemIndex, player.mediaItemCount, shufflePlaylistFirst) } } currentQueue = radioQueue } catch (e: Exception) { // Fallback: try with related endpoint try { val nextResult = withContext(Dispatchers.IO) { YouTube.next(WatchEndpoint(videoId = currentMediaId)).getOrNull() } nextResult?.relatedEndpoint?.let { relatedEndpoint -> val relatedPage = withContext(Dispatchers.IO) { YouTube.related(relatedEndpoint).getOrNull() } relatedPage?.songs?.let { songs -> val radioItems = songs .filter { it.id != currentMediaId } .map { it.toMediaItem() } .filterExplicit(dataStore.get(HideExplicitKey, false)) .filterVideoSongs(dataStore.get(HideVideoSongsKey, false)) if (radioItems.isNotEmpty()) { val itemCount = player.mediaItemCount if (itemCount > currentIndex + 1) { player.removeMediaItems(currentIndex + 1, itemCount) } player.addMediaItems(currentIndex + 1, radioItems) if (player.shuffleModeEnabled) { val shufflePlaylistFirst = dataStore.get(ShufflePlaylistFirstKey, false) applyShuffleOrder( player.currentMediaItemIndex, player.mediaItemCount, shufflePlaylistFirst ) } } } } } catch (_: Exception) { // Silent fail } } } } fun getAutomixAlbum(albumId: String) { scope.launch(SilentHandler) { YouTube .album(albumId) .onSuccess { getAutomix(it.album.playlistId) } } } fun getAutomix(playlistId: String) { if (dataStore.get(SimilarContent, true) && !(dataStore.get(DisableLoadMoreWhenRepeatAllKey, false) && player.repeatMode == REPEAT_MODE_ALL) ) { scope.launch(SilentHandler) { try { // Try primary method YouTube.next(WatchEndpoint(playlistId = playlistId)) .onSuccess { firstResult -> YouTube.next(WatchEndpoint(playlistId = firstResult.endpoint.playlistId)) .onSuccess { secondResult -> automixItems.value = secondResult.items.map { song -> song.toMediaItem() } } .onFailure { // Fallback: use first result items if (firstResult.items.isNotEmpty()) { automixItems.value = firstResult.items.map { song -> song.toMediaItem() } } } } .onFailure { // Fallback: try with radio format val currentSong = player.currentMetadata if (currentSong != null) { // Use simple videoId for better personalized recommendations YouTube.next( WatchEndpoint( videoId = currentSong.id ) ).onSuccess { radioResult -> val filteredItems = radioResult.items .filter { it.id != currentSong.id } .map { it.toMediaItem() } if (filteredItems.isNotEmpty()) { automixItems.value = filteredItems } }.onFailure { // Final fallback: try related endpoint YouTube.next(WatchEndpoint(videoId = currentSong.id)) .getOrNull()?.relatedEndpoint?.let { relatedEndpoint -> YouTube.related(relatedEndpoint).onSuccess { relatedPage -> val relatedItems = relatedPage.songs .filter { it.id != currentSong.id } .map { it.toMediaItem() } if (relatedItems.isNotEmpty()) { automixItems.value = relatedItems } } } } } } } catch (_: Exception) { // Silent fail } } } } 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) { // If queue is empty or player is idle, play immediately instead if (player.mediaItemCount == 0 || player.playbackState == STATE_IDLE) { player.setMediaItems(items) player.prepare() // Don't start local playback if casting if (castConnectionHandler?.isCasting?.value != true) { player.play() } return } // Remove duplicates if enabled if (dataStore.get(PreventDuplicateTracksInQueueKey, false)) { val itemIds = items.map { it.mediaId }.toSet() val indicesToRemove = mutableListOf() val currentIndex = player.currentMediaItemIndex for (i in 0 until player.mediaItemCount) { if (i != currentIndex && player.getMediaItemAt(i).mediaId in itemIds) { indicesToRemove.add(i) } } // Remove from highest index to lowest to maintain index stability indicesToRemove.sortedDescending().forEach { index -> player.removeMediaItem(index) } } val insertIndex = player.currentMediaItemIndex + 1 val shuffleEnabled = player.shuffleModeEnabled // Insert items immediately after the current item in the window/index space player.addMediaItems(insertIndex, items) player.prepare() if (shuffleEnabled) { // Rebuild shuffle order so that newly inserted items are played next val timeline = player.currentTimeline if (!timeline.isEmpty) { val size = timeline.windowCount val currentIndex = player.currentMediaItemIndex // Newly inserted indices are a contiguous range [insertIndex, insertIndex + items.size) val newIndices = (insertIndex until (insertIndex + items.size)).toSet() // Collect existing shuffle traversal order excluding current index 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() // preserve original forward order val existingOrder = (prevList + orderAfter).filter { it != currentIndex && it !in newIndices } // Build new shuffle order: current -> newly inserted (in insertion order) -> rest 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 } // Fill any missing indices (safety) to ensure a full permutation 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) { // Remove duplicates if enabled if (dataStore.get(PreventDuplicateTracksInQueueKey, false)) { val itemIds = items.map { it.mediaId }.toSet() val indicesToRemove = mutableListOf() val currentIndex = player.currentMediaItemIndex for (i in 0 until player.mediaItemCount) { if (i != currentIndex && player.getMediaItemAt(i).mediaId in itemIds) { indicesToRemove.add(i) } } // Remove from highest index to lowest to maintain index stability indicesToRemove.sortedDescending().forEach { index -> player.removeMediaItem(index) } } player.addMediaItems(items) if (player.shuffleModeEnabled) { val shufflePlaylistFirst = dataStore.get(ShufflePlaylistFirstKey, false) applyShuffleOrder(player.currentMediaItemIndex, player.mediaItemCount, shufflePlaylistFirst) } player.prepare() } fun toggleLibrary() { scope.launch { val songToToggle = currentSong.first() songToToggle?.let { val isInLibrary = it.song.inLibrary != null val token = if (isInLibrary) it.song.libraryRemoveToken else it.song.libraryAddToken // Call YouTube API with feedback token if available token?.let { feedbackToken -> YouTube.feedback(listOf(feedbackToken)) } // Update local database database.query { update(it.song.toggleLibrary()) } currentMediaMetadata.value = player.currentMetadata } } } fun toggleLike() { scope.launch { val songToToggle = currentSong.first() songToToggle?.let { librarySong -> val songEntity = librarySong.song // For podcast episodes, toggle save for later instead of like if (songEntity.isEpisode) { toggleEpisodeSaveForLater(songEntity) return@let } val song = songEntity.toggleLike() database.query { update(song) syncUtils.likeSong(song) // Check if auto-download on like is enabled and the song is now liked if (dataStore.get(AutoDownloadOnLikeKey, false) && song.liked) { // Trigger download for the liked song val downloadRequest = androidx.media3.exoplayer.offline.DownloadRequest .Builder(song.id, song.id.toUri()) .setCustomCacheKey(song.id) .setData(song.title.toByteArray()) .build() androidx.media3.exoplayer.offline.DownloadService.sendAddDownload( this@MusicService, ExoDownloadService::class.java, downloadRequest, false ) } } currentMediaMetadata.value = player.currentMetadata } } } fun addToTargetPlaylist() { scope.launch { val currentSong = currentSong.first() ?: return@launch val targetPlaylistId = dataStore.get(AndroidAutoTargetPlaylistKey, MediaSessionConstants.TARGET_PLAYLIST_AUTO) if (targetPlaylistId == MediaSessionConstants.TARGET_PLAYLIST_AUTO) { Handler(Looper.getMainLooper()).post { Toast.makeText( this@MusicService, getString(R.string.android_auto_target_playlist_not_set), Toast.LENGTH_SHORT ).show() } return@launch } database.query { insert( com.metrolist.music.db.entities.PlaylistSongMap( playlistId = targetPlaylistId, songId = currentSong.id, position = Int.MAX_VALUE ) ) } } } private suspend fun toggleEpisodeSaveForLater(songEntity: com.metrolist.music.db.entities.SongEntity) { val isCurrentlySaved = songEntity.inLibrary != null val shouldBeSaved = !isCurrentlySaved // Update database first (optimistic update) // Also ensure isEpisode = true so it appears in saved episodes list database.query { update(songEntity.copy( inLibrary = if (isCurrentlySaved) null else java.time.LocalDateTime.now(), isEpisode = true )) } currentMediaMetadata.value = player.currentMetadata // Sync with YouTube (handles login check internally) val setVideoId = if (isCurrentlySaved) database.getSetVideoId(songEntity.id)?.setVideoId else null syncUtils.saveEpisode(songEntity.id, shouldBeSaved, setVideoId) } fun toggleStartRadio() { startRadioSeamlessly() } private fun setupLoudnessEnhancer() { val audioSessionId = player.audioSessionId if (audioSessionId == C.AUDIO_SESSION_ID_UNSET || audioSessionId <= 0) { Timber.tag(TAG) .w("setupLoudnessEnhancer: invalid audioSessionId ($audioSessionId), cannot create effect yet") return } // Create or recreate enhancer if needed if (loudnessEnhancer == null) { try { loudnessEnhancer = LoudnessEnhancer(audioSessionId) Timber.tag(TAG).d("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() } Timber.tag(TAG).d("Audio normalization enabled: $normalizeAudio") Timber.tag(TAG) .d("Format loudnessDb: ${format?.loudnessDb}, perceptualLoudnessDb: ${format?.perceptualLoudnessDb}") // Use loudnessDb if available, otherwise fall back to perceptualLoudnessDb val loudness = format?.loudnessDb ?: format?.perceptualLoudnessDb withContext(Dispatchers.Main) { if (loudness != null) { val loudnessDb = loudness.toFloat() val targetGain = (-loudnessDb * 100).toInt() val clampedGain = targetGain.coerceIn(MIN_GAIN_MB, MAX_GAIN_MB) Timber.tag(TAG) .d("Calculated raw normalization gain: $targetGain mB (from loudness: $loudnessDb)") try { loudnessEnhancer?.setTargetGain(clampedGain) loudnessEnhancer?.enabled = true Timber.tag(TAG).i("LoudnessEnhancer gain applied: $clampedGain mB") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to apply loudness enhancement") reportException(e) releaseLoudnessEnhancer() } } else { loudnessEnhancer?.enabled = false Timber.tag(TAG) .w("Normalization enabled but no loudness data available - no normalization applied") } } } else { withContext(Dispatchers.Main) { loudnessEnhancer?.enabled = false Timber.tag(TAG).d("setupLoudnessEnhancer: normalization disabled or mediaId unavailable") } } } catch (e: Exception) { reportException(e) releaseLoudnessEnhancer() } } } private fun releaseLoudnessEnhancer() { try { loudnessEnhancer?.release() Timber.tag(TAG).d("LoudnessEnhancer released") } catch (e: Exception) { reportException(e) Timber.tag(TAG).e(e, "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) }, ) } private var previousMediaItemIndex = C.INDEX_UNSET private var previousEpisodeId: String? = null private var previousEpisodePosition: Long = 0L /** * Save podcast episode playback position to database. * Only saves if the item is an episode and position is meaningful (> 3 seconds). */ private fun saveEpisodePosition(episodeId: String, positionMs: Long) { if (positionMs < 3000) return // Don't save if less than 3 seconds played scope.launch(Dispatchers.IO + SilentHandler) { database.updatePlaybackPosition(episodeId, positionMs) Timber.tag(TAG).d("Saved episode position: $episodeId at ${positionMs}ms") } } /** * Restore podcast episode playback position from database. * Seeks to saved position if available. */ private fun restoreEpisodePosition(episodeId: String) { scope.launch(Dispatchers.IO + SilentHandler) { val savedPosition = database.getPlaybackPosition(episodeId) if (savedPosition != null && savedPosition > 0) { withContext(Dispatchers.Main) { // Only seek if we're still on the same episode if (player.currentMediaItem?.mediaId == episodeId) { player.seekTo(savedPosition) Timber.tag(TAG).d("Restored episode position: $episodeId to ${savedPosition}ms") } } } } } override fun onMediaItemTransition( mediaItem: MediaItem?, reason: Int, ) { // Save previous episode position if it was an episode previousEpisodeId?.let { episodeId -> if (previousEpisodePosition > 0) { saveEpisodePosition(episodeId, previousEpisodePosition) } } previousEpisodeId = null previousEpisodePosition = 0L // Check if new item is an episode and restore its position val newMetadata = mediaItem?.metadata if (newMetadata?.isEpisode == true) { previousEpisodeId = newMetadata.id // Delay restoration to let playback start scope.launch { delay(100) restoreEpisodePosition(newMetadata.id) } } // Force Repeat One if the player ignored it and auto-advanced if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { val repeatMode = runBlocking { dataStore.get(RepeatModeKey, REPEAT_MODE_OFF) } if (repeatMode == REPEAT_MODE_ONE && previousMediaItemIndex != C.INDEX_UNSET && previousMediaItemIndex != player.currentMediaItemIndex ) { player.seekTo(previousMediaItemIndex, 0) } } previousMediaItemIndex = player.currentMediaItemIndex lastPlaybackSpeed = -1.0f // force update song setupLoudnessEnhancer() discordUpdateJob?.cancel() scrobbleManager?.onSongStop() if (player.playWhenReady && player.playbackState == Player.STATE_READY) { scrobbleManager?.onSongStart(player.currentMetadata, duration = player.duration) } // Sync Cast when media changes and Cast is connected // Skip if this change was triggered by Cast sync (to prevent loops) if (castConnectionHandler?.isCasting?.value == true && castConnectionHandler?.isSyncingFromCast != true && mediaItem != null ) { val metadata = mediaItem.metadata if (metadata != null) { // Try to navigate to the item if it's already in Cast queue // This avoids a full reload which causes the widget to refresh val navigated = castConnectionHandler?.navigateToMediaIfInQueue(metadata.id) ?: false if (!navigated) { // Item not in Cast queue, need to reload castConnectionHandler?.loadMedia(metadata) } } } // Auto load more songs from queue 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 = withContext(Dispatchers.IO) { currentQueue.nextPage() .filterExplicit(dataStore.get(HideExplicitKey, false)) .filterVideoSongs(dataStore.get(HideVideoSongsKey, false)) } if (player.playbackState != STATE_IDLE && mediaItems.isNotEmpty()) { player.addMediaItems(mediaItems) if (player.shuffleModeEnabled) { val shufflePlaylistFirst = dataStore.get(ShufflePlaylistFirstKey, false) applyShuffleOrder(player.currentMediaItemIndex, player.mediaItemCount, shufflePlaylistFirst) } } } } // Save state when media item changes if (dataStore.get(PersistentQueueKey, true)) { saveQueueToDisk() } } override fun onPlaybackStateChanged( @Player.State playbackState: Int, ) { // Force Repeat All if the player ignored it and ended playback if (playbackState == Player.STATE_ENDED) { val repeatMode = runBlocking { dataStore.get(RepeatModeKey, REPEAT_MODE_OFF) } if (repeatMode == REPEAT_MODE_ALL && player.mediaItemCount > 0) { player.seekTo(0, 0) player.prepare() player.play() } } // Save state when playback state changes (but not during silence skipping) if (dataStore.get(PersistentQueueKey, true) && !isSilenceSkipping) { saveQueueToDisk() } if (playbackState == Player.STATE_READY) { consecutivePlaybackErr = 0 retryCount = 0 waitingForNetworkConnection.value = false retryJob?.cancel() // Reset retry count for current song on successful playback player.currentMediaItem?.mediaId?.let { mediaId -> resetRetryCount(mediaId) Timber.tag(TAG).d("Playback successful for $mediaId, reset retry count") } scheduleCrossfade() } if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { scrobbleManager?.onSongStop() } } override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { // Safety net: if local player tries to start while casting, immediately pause it if (playWhenReady && castConnectionHandler?.isCasting?.value == true) { player.pause() return } if (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) { if (playWhenReady) { isPausedByVolumeMute = false } if (!playWhenReady && !isPausedByVolumeMute) { wasPlayingBeforeVolumeMute = false } } // Save episode position when pausing if (!playWhenReady) { val currentMetadata = player.currentMediaItem?.metadata if (currentMetadata?.isEpisode == true && player.currentPosition > 0) { saveEpisodePosition(currentMetadata.id, player.currentPosition) previousEpisodePosition = player.currentPosition } } if (playWhenReady) { setupLoudnessEnhancer() } } override fun onEvents( player: Player, events: Player.Events, ) { if (events.containsAny( Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_PLAY_WHEN_READY_CHANGED ) ) { scheduleCrossfade() val isBufferingOrReady = player.playbackState == Player.STATE_BUFFERING || player.playbackState == Player.STATE_READY if (isBufferingOrReady && player.playWhenReady) { val focusGranted = requestAudioFocus() if (focusGranted) { openAudioEffectSession() } } else { closeAudioEffectSession() } } if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { currentMediaMetadata.value = player.currentMetadata } // Widget and Discord RPC updates if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) { updateWidgetUI(player.isPlaying) if (player.isPlaying) { startWidgetUpdates() } else { stopWidgetUpdates() } if (!player.isPlaying && !events.containsAny( Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_MEDIA_ITEM_TRANSITION ) ) { scope.launch { discordRpc?.close() } } } // Update Discord RPC when media item changes or playback starts if (events.containsAny( Player.EVENT_MEDIA_ITEM_TRANSITION, Player.EVENT_IS_PLAYING_CHANGED ) && player.isPlaying ) { val mediaId = player.currentMetadata?.id if (mediaId != null) { scope.launch { // Fetch song from database to get full info database.song(mediaId).first()?.let { song -> updateDiscordRPC(song) } } } } // Scrobbling if (events.containsAny(Player.EVENT_IS_PLAYING_CHANGED)) { scrobbleManager?.onPlayerStateChanged(player.isPlaying, player.currentMetadata, duration = player.duration) } } override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { updateNotification() if (shuffleModeEnabled) { // If queue is empty, don't shuffle if (player.mediaItemCount == 0) return val shufflePlaylistFirst = dataStore.get(ShufflePlaylistFirstKey, false) val currentIndex = player.currentMediaItemIndex val totalCount = player.mediaItemCount applyShuffleOrder(currentIndex, totalCount, shufflePlaylistFirst) } // Save shuffle mode to preferences if (dataStore.get(RememberShuffleAndRepeatKey, true)) { scope.launch { dataStore.edit { settings -> settings[ShuffleModeKey] = shuffleModeEnabled } } } // Save state when shuffle mode changes if (dataStore.get(PersistentQueueKey, true)) { saveQueueToDisk() } } override fun onRepeatModeChanged(repeatMode: Int) { updateNotification() scope.launch { dataStore.edit { settings -> settings[RepeatModeKey] = repeatMode } } // Save state when repeat mode changes if (dataStore.get(PersistentQueueKey, true)) { saveQueueToDisk() } } /** * Applies a new shuffle order to the player, maintaining the current item's position. * If `shufflePlaylistFirst` is true, it attempts to shuffle original items separately from added items. */ private fun applyShuffleOrder( currentIndex: Int, totalCount: Int, shufflePlaylistFirst: Boolean ) { if (totalCount == 0) return if (shufflePlaylistFirst && originalQueueSize > 0 && originalQueueSize < totalCount) { // Shuffle original items and added items separately val originalIndices = (0 until originalQueueSize).filter { it != currentIndex }.toMutableList() val addedIndices = (originalQueueSize until totalCount).filter { it != currentIndex }.toMutableList() originalIndices.shuffle() addedIndices.shuffle() val shuffledIndices = IntArray(totalCount) var pos = 0 shuffledIndices[pos++] = currentIndex if (currentIndex < originalQueueSize) { originalIndices.forEach { shuffledIndices[pos++] = it } addedIndices.forEach { shuffledIndices[pos++] = it } } else { (0 until originalQueueSize).shuffled().forEach { shuffledIndices[pos++] = it } addedIndices.forEach { shuffledIndices[pos++] = it } } player.setShuffleOrder(DefaultShuffleOrder(shuffledIndices, System.currentTimeMillis())) } else { val shuffledIndices = IntArray(totalCount) { it } shuffledIndices.shuffle() // Ensure current item is first in the shuffle order val currentItemIndexInShuffled = shuffledIndices.indexOf(currentIndex) if (currentItemIndexInShuffled != -1) { // Should always be true if totalCount > 0 val temp = shuffledIndices[0] shuffledIndices[0] = shuffledIndices[currentItemIndexInShuffled] shuffledIndices[currentItemIndexInShuffled] = temp } player.setShuffleOrder(DefaultShuffleOrder(shuffledIndices, System.currentTimeMillis())) } } override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { super.onPlaybackParametersChanged(playbackParameters) if (playbackParameters.speed != lastPlaybackSpeed) { lastPlaybackSpeed = playbackParameters.speed discordUpdateJob?.cancel() // update scheduling thingy discordUpdateJob = scope.launch { delay(1000) if (player.playWhenReady && player.playbackState == Player.STATE_READY) { currentSong.value?.let { song -> updateDiscordRPC(song) } } } } } /** * Extracts the HTTP response code from an error's cause chain. * Returns null if no HTTP response code is found. */ private fun getHttpResponseCode(error: PlaybackException): Int? { var cause: Throwable? = error.cause while (cause != null) { if (cause is HttpDataSource.InvalidResponseCodeException) { return cause.responseCode } cause = cause.cause } return null } /** * Checks if the error is caused by an expired/forbidden URL (HTTP 403). * This typically happens when a YouTube stream URL expires. */ private fun isExpiredUrlError(error: PlaybackException): Boolean { val responseCode = getHttpResponseCode(error) return responseCode == 403 } /** * Checks if the error is a Range Not Satisfiable error (HTTP 416). * This happens when cached data doesn't match the actual stream size. */ private fun isRangeNotSatisfiableError(error: PlaybackException): Boolean { val responseCode = getHttpResponseCode(error) return responseCode == 416 } /** * Checks if the error is a "page needs to be reloaded" error. * This is a YouTube-specific error that requires refreshing the stream. */ private fun isPageReloadError(error: PlaybackException): Boolean { val errorMessage = error.message?.lowercase() ?: "" val causeMessage = error.cause?.message?.lowercase() ?: "" val innerCauseMessage = error.cause?.cause?.message?.lowercase() ?: "" val reloadKeywords = listOf( "page needs to be reloaded", "pagina deve essere ricaricata", "la pagina deve essere ricaricata", "page must be reloaded", "reload", "ricaricata" ) return reloadKeywords.any { keyword -> errorMessage.contains(keyword) || causeMessage.contains(keyword) || innerCauseMessage.contains(keyword) } } private fun isNetworkRelatedError(error: PlaybackException): Boolean { // Don't treat specific errors as network errors - they need special handling if (isExpiredUrlError(error) || isRangeNotSatisfiableError(error) || isPageReloadError(error)) { return false } return error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED || error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT || error.errorCode == PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE || error.cause is java.net.ConnectException || error.cause is java.net.UnknownHostException || (error.cause as? PlaybackException)?.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED } /** * Checks if the error is caused by AudioTrack write or initialization failures. * These errors indicate the audio renderer is in a corrupted/invalid state. */ private fun isAudioRendererError(error: PlaybackException): Boolean { return error.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED || error.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED || (error.cause as? PlaybackException)?.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED || (error.cause as? PlaybackException)?.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED } override fun onPlayerError(error: PlaybackException) { super.onPlayerError(error) // Safety check : ensuring player is still initialized if (!playerInitialized.value) { Timber.tag(TAG).e(error, "Player error occurred but player not initialized") return } val mediaId = player.currentMediaItem?.mediaId Timber.tag(TAG) .w(error, "Player error occurred for $mediaId: errorCode=${error.errorCode}, message=${error.message}") reportException(error) // Check if this song has failed too many times if (mediaId != null && hasExceededRetryLimit(mediaId)) { Timber.tag(TAG).w("Song $mediaId has exceeded retry limit, skipping") markSongAsFailed(mediaId) handleFinalFailure() return } // Aggressive cache clearing for all playback errors if (mediaId != null) { performAggressiveCacheClear(mediaId) } // Handle specific error types with strict strategies when { isAudioRendererError(error) -> { Timber.tag(TAG).d("AudioTrack error detected (${error.errorCode}), performing safe recovery") handleAudioRendererError(mediaId) return } isRangeNotSatisfiableError(error) -> { Timber.tag(TAG).d("Range Not Satisfiable (416) detected, performing strict recovery") handleRangeNotSatisfiableError(mediaId) return } isPageReloadError(error) -> { Timber.tag(TAG).d("Page reload error detected, performing strict recovery") handlePageReloadError(mediaId) return } isExpiredUrlError(error) -> { Timber.tag(TAG).d("Expired URL (403) detected, refreshing stream URL") handleExpiredUrlError(mediaId) return } !isNetworkConnected.value || isNetworkRelatedError(error) -> { Timber.tag(TAG).d("Network-related error detected, waiting for connection") waitOnNetworkError() return } } // For IO_UNSPECIFIED and IO_BAD_HTTP_STATUS, try recovery first if (error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED || error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS ) { Timber.tag(TAG).d("IO error detected (${error.errorCode}), attempting recovery") handleGenericIOError(mediaId) return } // Final fallback if (dataStore.get(AutoSkipNextOnErrorKey, false)) { Timber.tag(TAG).d("Auto-skipping to next track due to unrecoverable error") skipOnError() } else { Timber.tag(TAG).d("Stopping playback due to unrecoverable error") stopOnError() } } /** * Performs aggressive cache clearing for a media item. * Clears both player cache and download cache, plus URL cache. */ private fun performAggressiveCacheClear(mediaId: String) { Timber.tag(TAG).d("Performing aggressive cache clear for $mediaId") // Clear URL cache songUrlCache.remove(mediaId) // Clear player cache try { playerCache.removeResource(mediaId) Timber.tag(TAG).d("Cleared player cache for $mediaId") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to clear player cache for $mediaId") } // Clear decryption caches try { YTPlayerUtils.forceRefreshForVideo(mediaId) Timber.tag(TAG).d("Cleared decryption caches for $mediaId") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to clear decryption caches for $mediaId") } } /** * Checks if a song has exceeded the retry limit. */ private fun hasExceededRetryLimit(mediaId: String): Boolean { val currentRetries = currentMediaIdRetryCount[mediaId] ?: 0 return currentRetries >= MAX_RETRY_PER_SONG } /** * Increments the retry count for a song. */ private fun incrementRetryCount(mediaId: String) { val currentRetries = currentMediaIdRetryCount[mediaId] ?: 0 currentMediaIdRetryCount[mediaId] = currentRetries + 1 Timber.tag(TAG).d("Retry count for $mediaId: ${currentRetries + 1}/$MAX_RETRY_PER_SONG") } /** * Resets the retry count for a song (called on successful playback). */ private fun resetRetryCount(mediaId: String) { currentMediaIdRetryCount.remove(mediaId) recentlyFailedSongs.remove(mediaId) } /** * Marks a song as failed to prevent further retry attempts. */ private fun markSongAsFailed(mediaId: String) { recentlyFailedSongs.add(mediaId) currentMediaIdRetryCount.remove(mediaId) // Schedule cleanup of failed songs list after 5 minutes failedSongsClearJob?.cancel() failedSongsClearJob = scope.launch { delay(5 * 60 * 1000L) // 5 minutes recentlyFailedSongs.clear() Timber.tag(TAG).d("Cleared recently failed songs list") } } /** * Handles AudioTrack errors (write failed, init failed) with safe recovery. * These errors indicate the audio renderer is corrupted and needs careful reset. */ private fun handleAudioRendererError(mediaId: String?) { if (mediaId == null) { handleFinalFailure() return } incrementRetryCount(mediaId) retryJob?.cancel() retryJob = scope.launch { try { // Pause playback immediately to stop the renderer player.pause() Timber.tag(TAG).d("Paused playback due to AudioTrack error") // Wait longer for audio renderer to settle before retry // This prevents the renderer from continuing to fail in a loop delay(RETRY_DELAY_MS * 3) // 3 seconds instead of 1 second // Check if player is still initialized before attempting recovery if (!playerInitialized.value) { Timber.tag(TAG).w("Player no longer initialized, aborting AudioTrack recovery") return@launch } val currentIndex = player.currentMediaItemIndex if (currentIndex != C.INDEX_UNSET) { // Seek to current position to force a clean audio renderer reinit val currentPosition = player.currentPosition player.seekTo(currentIndex, currentPosition) player.prepare() Timber.tag(TAG).d("Retrying playback for $mediaId after AudioTrack error") // Resume playback if it wasn't paused by user if (wasPlayingBeforeAudioFocusLoss) { delay(500) // Brief delay to allow renderer to be ready if (hasAudioFocus && playerInitialized.value) { if (castConnectionHandler?.isCasting?.value != true) { player.play() } } } } else { Timber.tag(TAG).w("Invalid media item index during AudioTrack recovery") handleFinalFailure() } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error during AudioTrack error recovery") handleFinalFailure() } } } /** * Handles Range Not Satisfiable (416) errors with strict recovery. * This error occurs when cached data doesn't match the actual stream size. */ private fun handleRangeNotSatisfiableError(mediaId: String?) { if (mediaId == null) { handleFinalFailure() return } incrementRetryCount(mediaId) retryJob?.cancel() retryJob = scope.launch { // Clear all caches aggressively performAggressiveCacheClear(mediaId) // Wait before retry delay(RETRY_DELAY_MS) // Force re-prepare from position 0 to avoid range issues val currentIndex = player.currentMediaItemIndex player.seekTo(currentIndex, 0) player.prepare() Timber.tag(TAG).d("Retrying playback for $mediaId after 416 error (from position 0)") } } /** * Handles "page needs to be reloaded" errors with strict recovery. * This requires clearing decryption caches and getting fresh stream URLs. */ private fun handlePageReloadError(mediaId: String?) { if (mediaId == null) { handleFinalFailure() return } incrementRetryCount(mediaId) retryJob?.cancel() retryJob = scope.launch { Timber.tag(TAG).d("Handling page reload error for $mediaId") // Clear all caches including decryption caches performAggressiveCacheClear(mediaId) // Additional delay for page reload errors as they may be rate-limited delay(RETRY_DELAY_MS * 2) // Re-prepare the player val currentPosition = player.currentPosition val currentIndex = player.currentMediaItemIndex player.seekTo(currentIndex, currentPosition) player.prepare() Timber.tag(TAG).d("Retrying playback for $mediaId after page reload error") } } /** * Handles expired URL (403) errors by clearing caches and retrying. */ private fun handleExpiredUrlError(mediaId: String?) { if (mediaId == null) { handleFinalFailure() return } incrementRetryCount(mediaId) // Clear the cached URL songUrlCache.remove(mediaId) Timber.tag(TAG).d("Cleared cached URL for $mediaId") // Clear decryption caches try { YTPlayerUtils.forceRefreshForVideo(mediaId) } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to clear decryption caches") } retryJob?.cancel() retryJob = scope.launch { delay(RETRY_DELAY_MS) // Seek to current position to force URL re-resolution val currentPosition = player.currentPosition val currentIndex = player.currentMediaItemIndex player.seekTo(currentIndex, currentPosition) player.prepare() Timber.tag(TAG).d("Retrying playback for $mediaId after 403 error") } } /** * Handles generic IO errors with recovery attempt. */ private fun handleGenericIOError(mediaId: String?) { if (mediaId == null) { handleFinalFailure() return } incrementRetryCount(mediaId) retryJob?.cancel() retryJob = scope.launch { performAggressiveCacheClear(mediaId) delay(RETRY_DELAY_MS) val currentPosition = player.currentPosition val currentIndex = player.currentMediaItemIndex player.seekTo(currentIndex, currentPosition) player.prepare() Timber.tag(TAG).d("Retrying playback for $mediaId after generic IO error") } } /** * Handles final failure when all recovery attempts have been exhausted. */ private fun handleFinalFailure() { if (dataStore.get(AutoSkipNextOnErrorKey, false)) { Timber.tag(TAG).d("All recovery attempts exhausted, auto-skipping to next track") skipOnError() } else { Timber.tag(TAG).d("All recovery attempts exhausted, stopping playback") stopOnError() } } override fun onDeviceVolumeChanged(volume: Int, muted: Boolean) { super.onDeviceVolumeChanged(volume, muted) val pauseOnMute = dataStore.get(PauseOnMute, false) if ((volume == 0 || muted) && pauseOnMute) { if (player.isPlaying) { wasPlayingBeforeVolumeMute = true isPausedByVolumeMute = true player.pause() } } else if (volume > 0 && !muted && pauseOnMute) { if (wasPlayingBeforeVolumeMute && !player.isPlaying && castConnectionHandler?.isCasting?.value != true) { wasPlayingBeforeVolumeMute = false isPausedByVolumeMute = false player.play() } } } private fun createCacheDataSource(): DataSource.Factory { val baseFactory = DefaultDataSource.Factory( this, OkHttpDataSource.Factory( OkHttpClient .Builder() .proxy(YouTube.proxy) .proxyAuthenticator { _, response -> YouTube.proxyAuth?.let { auth -> response.request.newBuilder() .header("Proxy-Authorization", auth) .build() } ?: response.request } .build(), ), ) return DataSource.Factory { val usePlayerCache = dataStore.get(EnableSongCacheKey, true) val upstreamFactory = if (usePlayerCache) { CacheDataSource.Factory() .setCache(playerCache) .setUpstreamDataSourceFactory(baseFactory) } else { baseFactory } CacheDataSource.Factory() .setCache(downloadCache) .setUpstreamDataSourceFactory(upstreamFactory) .setCacheWriteDataSinkFactory(null) .setFlags(FLAG_IGNORE_CACHE_ON_ERROR) .createDataSource() } } // Flag to prevent queue saving during silence skip operations private var isSilenceSkipping = false private fun handleLongSilenceDetected() { if (!instantSilenceSkipEnabled.value) return if (silenceSkipJob?.isActive == true) return silenceSkipJob = scope.launch { // Debounce so short fades or transitions do not trigger a jump. delay(200) performInstantSilenceSkip() } } private suspend fun performInstantSilenceSkip() { val duration = player.duration.takeIf { it != C.TIME_UNSET && it > 0 } ?: return if (duration <= INSTANT_SILENCE_SKIP_STEP_MS) return isSilenceSkipping = true try { var hops = 0 val silenceProcessor = playerSilenceProcessors[player] ?: return while (coroutineContext.isActive && instantSilenceSkipEnabled.value && silenceProcessor.isCurrentlySilent()) { val current = player.currentPosition val target = (current + INSTANT_SILENCE_SKIP_STEP_MS).coerceAtMost(duration - 500) if (target <= current) break // Reset silence tracking before seeking to prevent immediate re-trigger silenceProcessor.resetTracking() player.seekTo(target) hops++ if (hops >= 80 || target >= duration - 500) break delay(INSTANT_SILENCE_SKIP_SETTLE_MS) } if (hops > 0) { Timber.tag(TAG).d("Silence skip: jumped $hops times") } } finally { isSilenceSkipping = false } } private fun updateDiscordRPC(song: Song, showFeedback: Boolean = false) { val useDetails = dataStore.get(DiscordUseDetailsKey, false) val advancedMode = dataStore.get(DiscordAdvancedModeKey, false) val status = if (advancedMode) dataStore.get(DiscordStatusKey, "online") else "online" val b1Text = if (advancedMode) dataStore.get(DiscordButton1TextKey, "") else "" val b1Visible = if (advancedMode) dataStore.get(DiscordButton1VisibleKey, true) else true val b2Text = if (advancedMode) dataStore.get(DiscordButton2TextKey, "") else "" val b2Visible = if (advancedMode) dataStore.get(DiscordButton2VisibleKey, true) else true val activityType = if (advancedMode) dataStore.get(DiscordActivityTypeKey, "listening") else "listening" val activityName = if (advancedMode) dataStore.get(DiscordActivityNameKey, "") else "" discordUpdateJob?.cancel() discordUpdateJob = scope.launch { discordRpc?.updateSong( song, player.currentPosition, player.playbackParameters.speed, useDetails, status, b1Text, b1Visible, b2Text, b2Visible, activityType, activityName )?.onFailure { // Rate limited or error if (showFeedback) { Handler(Looper.getMainLooper()).post { Toast.makeText( this@MusicService, "Discord RPC update failed: ${it.message}", Toast.LENGTH_SHORT ).show() } } } } } private fun createDataSourceFactory(): DataSource.Factory { return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> val mediaId = dataSpec.key ?: error("No media id") // Check if we need to bypass cache for quality change val shouldBypassCache = bypassCacheForQualityChange.contains(mediaId) if (!shouldBypassCache) { val usePlayerCache = dataStore.get(EnableSongCacheKey, true) if (downloadCache.isCached( mediaId, dataSpec.position, if (dataSpec.length >= 0) dataSpec.length else 1 ) || (usePlayerCache && 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()) } } else { Timber.tag("MusicService").i("BYPASSING CACHE for $mediaId due to quality change") } Timber.tag("MusicService").i("FETCHING STREAM: $mediaId | quality=$audioQuality") val playbackData = runBlocking(Dispatchers.IO) { YTPlayerUtils.playerResponseForPlayback( mediaId, audioQuality = audioQuality, connectivityManager = connectivityManager, ) }.getOrElse { throwable -> when (throwable) { is PlaybackException -> throw throwable is java.net.ConnectException, is java.net.UnknownHostException -> { throw PlaybackException( getString(R.string.error_no_internet), throwable, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ) } is java.net.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 nonNullPlayback = requireNotNull(playbackData) { getString(R.string.error_unknown) } run { val format = nonNullPlayback.format val loudnessDb = nonNullPlayback.audioConfig?.loudnessDb val perceptualLoudnessDb = nonNullPlayback.audioConfig?.perceptualLoudnessDb Timber.tag(TAG) .d("Storing format for $mediaId with loudnessDb: $loudnessDb, perceptualLoudnessDb: $perceptualLoudnessDb") if (loudnessDb == null && perceptualLoudnessDb == null) { Timber.tag(TAG).w("No loudness data available from YouTube for video: $mediaId") } 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 = loudnessDb, perceptualLoudnessDb = perceptualLoudnessDb, playbackUrl = nonNullPlayback.playbackTracking?.videostatsPlaybackUrl?.baseUrl ) ) } scope.launch(Dispatchers.IO) { recoverSong(mediaId, nonNullPlayback) } // Clear bypass flag now that we've fetched fresh stream if (bypassCacheForQualityChange.remove(mediaId)) { Timber.tag("MusicService").d("Cleared bypass cache flag for $mediaId after fresh fetch") } val streamUrl = nonNullPlayback.streamUrl songUrlCache[mediaId] = streamUrl to System.currentTimeMillis() + (nonNullPlayback.streamExpiresInSeconds * 1000L) return@Factory dataSpec.withUri(streamUrl.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } } } private fun createMediaSourceFactory() = DefaultMediaSourceFactory( createDataSourceFactory(), ExtractorsFactory { arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) }, ) private fun createRenderersFactory( eqProcessor: CustomEqualizerAudioProcessor, silenceProcessor: SilenceDetectorAudioProcessor ) = object : DefaultRenderersFactory(this) { override fun buildAudioSink( context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, ) = DefaultAudioSink .Builder(this@MusicService) .setEnableFloatOutput(enableFloatOutput) .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) .setAudioProcessorChain( DefaultAudioSink.DefaultAudioProcessorChain( // 2. Inject processor into audio pipeline arrayOf( eqProcessor, silenceProcessor, ), 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 val historyDurationMs = dataStore[HistoryDuration]?.times(1000f) ?: 30000f if (playbackStats.totalPlayTimeMs >= historyDurationMs && !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) { } } } if (playbackStats.totalPlayTimeMs >= historyDurationMs) { 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.mediaItemCount == 0) { Timber.tag(TAG).d("Skipping queue save - no media items") return } try { // Save current queue with proper type information val persistQueue = currentQueue.toPersistQueue( title = queueTitle, items = player.mediaItems.mapNotNull { it.metadata }, mediaItemIndex = player.currentMediaItemIndex, position = player.currentPosition ) val persistAutomix = PersistQueue( title = "automix", items = automixItems.value.mapNotNull { it.metadata }, mediaItemIndex = 0, position = 0, ) // Save player state val persistPlayerState = PersistPlayerState( playWhenReady = player.playWhenReady, repeatMode = player.repeatMode, shuffleModeEnabled = player.shuffleModeEnabled, volume = playerVolume.value, currentPosition = player.currentPosition, currentMediaItemIndex = player.currentMediaItemIndex, playbackState = player.playbackState ) runCatching { filesDir.resolve(PERSISTENT_QUEUE_FILE).outputStream().use { fos -> ObjectOutputStream(fos).use { oos -> oos.writeObject(persistQueue) } } Timber.tag(TAG).d("Queue saved successfully") }.onFailure { Timber.tag(TAG).e(it, "Failed to save queue") reportException(it) } runCatching { filesDir.resolve(PERSISTENT_AUTOMIX_FILE).outputStream().use { fos -> ObjectOutputStream(fos).use { oos -> oos.writeObject(persistAutomix) } } Timber.tag(TAG).d("Automix saved successfully") }.onFailure { Timber.tag(TAG).e(it, "Failed to save automix") reportException(it) } runCatching { filesDir.resolve(PERSISTENT_PLAYER_STATE_FILE).outputStream().use { fos -> ObjectOutputStream(fos).use { oos -> oos.writeObject(persistPlayerState) } } Timber.tag(TAG).d("Player state saved successfully") }.onFailure { Timber.tag(TAG).e(it, "Failed to save player state") reportException(it) } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error during queue save operation") reportException(e) } } override fun onDestroy() { isRunning = false // Save episode position before destroying val currentMetadata = player.currentMediaItem?.metadata if (currentMetadata?.isEpisode == true && player.currentPosition > 0) { runBlocking(Dispatchers.IO) { database.updatePlaybackPosition(currentMetadata.id, player.currentPosition) } } try { unregisterReceiver(screenStateReceiver) } catch (e: Exception) { // Ignore } audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) castConnectionHandler?.release() if (dataStore.get(PersistentQueueKey, true)) { saveQueueToDisk() } if (discordRpc?.isRpcRunning() == true) { discordRpc?.closeRPC() } discordRpc = null connectivityObserver.unregister() abandonAudioFocus() releaseLoudnessEnhancer() mediaSession.release() player.removeListener(this) player.removeListener(sleepTimer) playerSilenceProcessors.remove(player) // Note: equalizerService audio processors are cleared in equalizerService.release() if needed, // or we can't easily reference the specific processor created in createExoPlayer here without storing it. // But since we are destroying the service, it's fine. player.release() discordUpdateJob?.cancel() super.onDestroy() } override fun onBind(intent: Intent?) = super.onBind(intent) ?: binder override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_ALARM_TRIGGER -> { handleAlarmTrigger(intent) } MusicWidgetReceiver.ACTION_PLAY_PAUSE -> { if (player.isPlaying) player.pause() else player.play() updateWidgetUI(player.isPlaying) } MusicWidgetReceiver.ACTION_LIKE -> { toggleLike() } MusicWidgetReceiver.ACTION_NEXT -> { player.seekToNext() updateWidgetUI(player.isPlaying) } MusicWidgetReceiver.ACTION_PREVIOUS -> { player.seekToPrevious() updateWidgetUI(player.isPlaying) } MusicWidgetReceiver.ACTION_UPDATE_WIDGET -> { updateWidgetUI(player.isPlaying) } } return super.onStartCommand(intent, flags, startId) } private fun handleAlarmTrigger(intent: Intent) { scope.launch(Dispatchers.IO) { try { MusicAlarmScheduler.scheduleFromPreferences(this@MusicService) } catch (t: Throwable) { Timber.tag(TAG).e(t, "Failed to reschedule alarms after trigger") } } val playlistId = intent.getStringExtra(EXTRA_ALARM_PLAYLIST_ID).orEmpty() val alarmId = intent.getStringExtra(EXTRA_ALARM_ID).orEmpty() if (playlistId.isBlank()) { if (alarmId.isNotBlank()) { scope.launch(Dispatchers.IO) { try { val alarms = MusicAlarmStore.load(this@MusicService) val updated = alarms.map { alarm -> if (alarm.id == alarmId) alarm.copy(enabled = false, nextTriggerAt = -1L) else alarm } MusicAlarmScheduler.scheduleAll(this@MusicService, updated) } catch (t: Throwable) { Timber.tag(TAG).e(t, "Failed to disable alarm with invalid playlist") } } } return } val randomSong = intent.getBooleanExtra(EXTRA_ALARM_RANDOM_SONG, false) scope.launch { try { val playlistSongs = withContext(Dispatchers.IO) { database.playlistSongs(playlistId).first() } if (playlistSongs.isEmpty()) { if (alarmId.isNotBlank()) { withContext(Dispatchers.IO) { val alarms = MusicAlarmStore.load(this@MusicService) val updated = alarms.map { alarm -> if (alarm.id == alarmId) alarm.copy(enabled = false, nextTriggerAt = -1L) else alarm } MusicAlarmScheduler.scheduleAll(this@MusicService, updated) } } return@launch } val items = playlistSongs.map { it.song.toMediaItem() } val playlistName = withContext(Dispatchers.IO) { database.playlist(playlistId).first()?.playlist?.name } withContext(Dispatchers.IO) { MusicAlarmScheduler.scheduleFromPreferences(this@MusicService) } val alarmItems = if (randomSong) { val firstIndex = Random.nextInt(items.size) buildList(items.size) { add(items[firstIndex]) items.forEachIndexed { index, item -> if (index != firstIndex) add(item) } } } else { items } player.stop() player.clearMediaItems() playQueue( ListQueue( title = playlistName, items = alarmItems, startIndex = 0, position = 0L ), playWhenReady = true ) } catch (t: Throwable) { Timber.tag(TAG).e(t, "Failed to start alarm playback") } } } /** * Updates all app widgets with current playback state */ private fun updateWidgetUI(isPlaying: Boolean) { scope.launch { try { val songData = currentSong.value val song = songData?.song val songTitle = song?.title ?: getString(R.string.no_song_playing) val artistName = songData?.artists?.joinToString(", ") { it.name } ?: getString(R.string.tap_to_open) val isLiked = songData?.song?.liked == true widgetManager.updateWidgets( title = songTitle, artist = artistName, artworkUri = song?.thumbnailUrl, isPlaying = isPlaying, isLiked = isLiked, duration = if (player.duration != C.TIME_UNSET) player.duration else 0, currentPosition = player.currentPosition ) } catch (e: Exception) { // Widget not added to home screen or other error } } } private var widgetUpdateJob: Job? = null private fun startWidgetUpdates() { widgetUpdateJob?.cancel() widgetUpdateJob = scope.launch { while (isActive) { if (player.isPlaying) { updateWidgetUI(true) } delay(200) } } } private fun stopWidgetUpdates() { widgetUpdateJob?.cancel() widgetUpdateJob = null } private fun shareSong() { val songData = currentSong.value val songId = songData?.song?.id ?: return val shareIntent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=$songId") addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } startActivity(Intent.createChooser(shareIntent, null).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } /** * Get the stream URL for a given media ID. * This is used for Google Cast to send the audio URL to Chromecast. */ suspend fun getStreamUrl(mediaId: String): String? { return withContext(Dispatchers.IO) { try { val playbackData = YTPlayerUtils.playerResponseForPlayback( videoId = mediaId, audioQuality = audioQuality, connectivityManager = connectivityManager, ).getOrNull() playbackData?.streamUrl } catch (e: Exception) { timber.log.Timber.e(e, "Failed to get stream URL for Cast") null } } } /** * Initialize Google Cast support */ private fun initializeCast() { if (dataStore.get(com.metrolist.music.constants.EnableGoogleCastKey, true)) { try { castConnectionHandler = CastConnectionHandler(this, scope, this) castConnectionHandler?.initialize() timber.log.Timber.d("Google Cast initialized") } catch (e: Exception) { timber.log.Timber.e(e, "Failed to initialize Google Cast") } } } override fun onPositionDiscontinuity( oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int ) { if (reason == Player.DISCONTINUITY_REASON_SEEK) { scheduleCrossfade() } } private fun scheduleCrossfade() { crossfadeTriggerJob?.cancel() crossfadeTriggerJob = null if (!crossfadeEnabled || player.duration == C.TIME_UNSET || player.duration <= crossfadeDuration) return if (crossfadeGapless && isNextItemGapless()) return if (!player.hasNextMediaItem() && player.repeatMode != REPEAT_MODE_ONE) return val triggerTime = player.duration - crossfadeDuration.toLong() val delayMs = triggerTime - player.currentPosition if (delayMs <= 0) return val targetMediaId = player.currentMediaItem?.mediaId crossfadeTriggerJob = scope.launch { delay(delayMs) if (isActive && player.isPlaying && player.currentMediaItem?.mediaId == targetMediaId && !sleepTimer.pauseWhenSongEnd) { startCrossfade() } } } private fun isNextItemGapless(): Boolean { val current = player.currentMediaItem?.mediaMetadata ?: return false val nextIndex = player.nextMediaItemIndex if (nextIndex == C.INDEX_UNSET) return false val next = player.getMediaItemAt(nextIndex).mediaMetadata return current.albumTitle != null && current.albumTitle == next.albumTitle } private fun startCrossfade() { if (isCrossfading) return // Preserve player state before creating the secondary player // Use runBlocking to ensure we get the correct state from DataStore val savedRepeatMode = runBlocking { dataStore.get(RepeatModeKey, REPEAT_MODE_OFF) } val savedShuffleEnabled = runBlocking { dataStore.get(ShuffleModeKey, false) } // For repeat-one, crossfade back into the same track val targetIndex = if (savedRepeatMode == REPEAT_MODE_ONE) { player.currentMediaItemIndex } else { player.nextMediaItemIndex } if (targetIndex == C.INDEX_UNSET) return secondaryPlayer = createExoPlayer() val secPlayer = secondaryPlayer!! secPlayer.addListener(secondaryPlayerListener) val itemCount = player.mediaItemCount val items = mutableListOf() // Copy entire queue history + future for (i in 0 until itemCount) { items.add(player.getMediaItemAt(i)) } secPlayer.setMediaItems(items) // Seek to target track (next track, or current track for repeat-one) secPlayer.seekTo(targetIndex, 0) secPlayer.volume = 0f // Copy repeat and shuffle state to the new player secPlayer.repeatMode = savedRepeatMode secPlayer.shuffleModeEnabled = savedShuffleEnabled secPlayer.prepare() secPlayer.playWhenReady = true performCrossfadeSwap() // Rebuild shuffle order on the new primary player if shuffle was active if (savedShuffleEnabled) { val shufflePlaylistFirst = dataStore.get(ShufflePlaylistFirstKey, false) applyShuffleOrder(player.currentMediaItemIndex, player.mediaItemCount, shufflePlaylistFirst) } } private fun performCrossfadeSwap() { isCrossfading = true val nextPlayer = secondaryPlayer ?: return val currentPlayer = player fadingPlayer = currentPlayer player = nextPlayer _playerFlow.value = player secondaryPlayer = null fadingPlayer?.removeListener(this) fadingPlayer?.removeListener(sleepTimer) // Add listener to sync play/pause state player.addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { if (isCrossfading && fadingPlayer != null) { if (isPlaying) { fadingPlayer?.play() } else { fadingPlayer?.pause() } } else { player.removeListener(this) } } }) nextPlayer.removeListener(secondaryPlayerListener) nextPlayer.addListener(this) nextPlayer.addListener(sleepTimer) sleepTimer.player = player try { (mediaSession as MediaSession).player = player } catch (e: Exception) { timber.log.Timber.e(e, "Failed to swap player in MediaSession") } crossfadeJob = scope.launch { val duration = crossfadeDuration.toLong() val steps = 20 val stepTime = duration / steps val startVolume = try { fadingPlayer?.volume ?: 1f } catch (e: Exception) { 1f } for (i in 0..steps) { if (!isActive) break // Pause volume ramp if player is paused while (!player.isPlaying && isActive) { delay(100) } val progress = i / steps.toFloat() val fadeIn = 1.0f - (1.0f - progress) * (1.0f - progress) val fadeOut = (1.0f - progress) * (1.0f - progress) try { player.volume = startVolume * fadeIn fadingPlayer?.volume = startVolume * fadeOut } catch (e: Exception) { break } delay(stepTime) } try { fadingPlayer?.volume = 0f player.volume = startVolume cleanupCrossfade() } catch (e: Exception) { } } } private fun cleanupCrossfade() { fadingPlayer?.stop() fadingPlayer?.clearMediaItems() fadingPlayer?.release() fadingPlayer = null isCrossfading = false applyEffectiveVolume() sleepTimer.notifySongTransition() } companion object { const val ACTION_ALARM_TRIGGER = "com.metrolist.music.action.ALARM_TRIGGER" const val EXTRA_ALARM_ID = "extra_alarm_id" const val EXTRA_ALARM_PLAYLIST_ID = "extra_alarm_playlist_id" const val EXTRA_ALARM_RANDOM_SONG = "extra_alarm_random_song" const val ROOT = "root" const val SONG = "song" const val ARTIST = "artist" const val ALBUM = "album" const val PLAYLIST = "playlist" const val YOUTUBE_PLAYLIST = "youtube_playlist" const val SEARCH = "search" const val SHUFFLE_ACTION = "__shuffle__" const val CHANNEL_ID = "music_channel_01" const val NOTIFICATION_ID = 888 const val ERROR_CODE_NO_STREAM = 1000001 const val CHUNK_LENGTH = 512 * 1024L const val PERSISTENT_QUEUE_FILE = "persistent_queue.data" const val PERSISTENT_AUTOMIX_FILE = "persistent_automix.data" const val PERSISTENT_PLAYER_STATE_FILE = "persistent_player_state.data" const val MAX_CONSECUTIVE_ERR = 5 const val MAX_RETRY_COUNT = 10 // Constants for audio normalization private const val MAX_GAIN_MB = 300 // Maximum gain in millibels (3 dB) private const val MIN_GAIN_MB = -1500 // Minimum gain in millibels (-15 dB) private const val TAG = "MusicService" @Volatile var isRunning = false private set } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/PlayerConnection.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback import android.content.Context 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_ENDED import androidx.media3.common.Timeline import androidx.media3.exoplayer.ExoPlayer import com.metrolist.music.constants.SleepTimerCustomDaysKey import com.metrolist.music.constants.SleepTimerDayTimesKey import com.metrolist.music.constants.SleepTimerDefaultKey import com.metrolist.music.constants.SleepTimerEnabledKey import com.metrolist.music.constants.SleepTimerEndTimeKey import com.metrolist.music.constants.SleepTimerRepeatKey import com.metrolist.music.constants.SleepTimerStartTimeKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.extensions.currentMetadata import com.metrolist.music.extensions.getCurrentQueueIndex import com.metrolist.music.extensions.getQueueWindows import com.metrolist.music.extensions.metadata import com.metrolist.music.extensions.togglePlayPause import com.metrolist.music.playback.MusicService.MusicBinder import com.metrolist.music.playback.queues.Queue import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.utils.reportException import kotlinx.coroutines.CoroutineScope 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.stateIn import kotlinx.coroutines.launch import timber.log.Timber import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter import kotlin.math.roundToInt @OptIn(ExperimentalCoroutinesApi::class) class PlayerConnection( context: Context, binder: MusicBinder, val database: MusicDatabase, scope: CoroutineScope, ) : Player.Listener { private companion object { private const val TAG = "PlayerConnection" } val service = binder.service private val playerReadinessFlow = service.isPlayerReady /** * Safe player accessor checks readiness & handles errors. * Should be used by all player access within this class. */ private fun getPlayerSafe(): ExoPlayer = try { if (!playerReadinessFlow.value) { Timber.tag(TAG).w("Player accessed before service initialization complete; returning best-effort reference") } service.player } catch (e: UninitializedPropertyAccessException) { Timber.tag(TAG).e(e, "Fatal: player property accessed but not initialized") throw IllegalStateException("MusicService.player not initialized; possible race condition in service startup", e) } /** * Public accessor for player. Throws if player not ready. * Callers should check [isPlayerInitialized] before calling, or handle exceptions. */ val player: ExoPlayer get() = getPlayerSafe() /** Tracks whether player initialization completed successfully */ private val isPlayerInitialized = MutableStateFlow(service.isPlayerReady.value) val playbackState: MutableStateFlow private val playWhenReady: MutableStateFlow val isPlaying: kotlinx.coroutines.flow.StateFlow init { Timber.tag(TAG).d("PlayerConnection init: playerReady=${playerReadinessFlow.value}") // Initialize with player state or safe defaults if player not ready val initialState = try { val initialPlayer = getPlayerSafe() Triple( initialPlayer.playbackState, initialPlayer.playWhenReady, initialPlayer.playWhenReady && initialPlayer.playbackState != STATE_ENDED, ) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error during PlayerConnection initialization, using defaults") Triple(Player.STATE_IDLE, false, false) } playbackState = MutableStateFlow(initialState.first) playWhenReady = MutableStateFlow(initialState.second) isPlaying = combine(playbackState, playWhenReady) { state, ready -> ready && state != STATE_ENDED }.stateIn( scope, SharingStarted.Lazily, initialState.third, ) // Track service readiness changes in background. scope.launch { playerReadinessFlow.collect { ready -> isPlayerInitialized.value = ready if (ready) { Timber.tag(TAG).d("Service player initialization detected by PlayerConnection") } } } Timber.tag(TAG).d("PlayerConnection state flows initialized successfully") } // Effective playing state, considers Cast when active val isEffectivelyPlaying = combine( isPlaying, service.castConnectionHandler?.isCasting ?: MutableStateFlow(false), service.castConnectionHandler?.castIsPlaying ?: MutableStateFlow(false), ) { localPlaying, isCasting, castPlaying -> if (isCasting) castPlaying else localPlaying }.stateIn( scope, SharingStarted.Lazily, player.playbackState != STATE_ENDED && player.playWhenReady, ) val mediaMetadata = MutableStateFlow(player.currentMetadata) val currentSong = mediaMetadata.flatMapLatest { database.song(it?.id) } val currentLyrics = mediaMetadata.flatMapLatest { mediaMetadata -> database.lyrics(mediaMetadata?.id) } val currentFormat = mediaMetadata.flatMapLatest { mediaMetadata -> database.format(mediaMetadata?.id) } val queueTitle = MutableStateFlow(null) val queueWindows = MutableStateFlow>(emptyList()) val currentMediaItemIndex = MutableStateFlow(-1) val currentWindowIndex = MutableStateFlow(-1) val shuffleModeEnabled = MutableStateFlow(false) val repeatMode = MutableStateFlow(REPEAT_MODE_OFF) val canSkipPrevious = MutableStateFlow(true) val canSkipNext = MutableStateFlow(true) val error = MutableStateFlow(null) val isMuted = service.isMuted val waitingForNetworkConnection = service.waitingForNetworkConnection // Callback to check if playback changes should be blocked (e.g., Listen Together guest) var shouldBlockPlaybackChanges: (() -> Boolean)? = null // Flag to allow internal sync operations to bypass blocking (set by ListenTogetherManager) @Volatile var allowInternalSync: Boolean = false var onSkipPrevious: (() -> Unit)? = null var onSkipNext: (() -> Unit)? = null private var attachedPlayer: Player? = null init { try { // Observe player changes (e.g. crossfade swap) scope.launch { service.playerFlow.collect { newPlayer -> if (newPlayer != null && newPlayer != attachedPlayer) { updateAttachedPlayer(newPlayer) } } } // Initial setup if flow hasn't emitted yet but service is ready if (attachedPlayer == null && service.isPlayerReady.value) { updateAttachedPlayer(player) } Timber.tag(TAG).d("PlayerConnection flow observer registered") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to initialize PlayerConnection listener or state") // Propagate the error so MainActivity can retry throw e } } private fun updateAttachedPlayer(newPlayer: Player) { attachedPlayer?.removeListener(this) attachedPlayer = newPlayer newPlayer.addListener(this) // Refresh all state from new player playbackState.value = newPlayer.playbackState playWhenReady.value = newPlayer.playWhenReady mediaMetadata.value = newPlayer.currentMetadata queueTitle.value = service.queueTitle queueWindows.value = newPlayer.getQueueWindows() currentWindowIndex.value = newPlayer.getCurrentQueueIndex() currentMediaItemIndex.value = newPlayer.currentMediaItemIndex shuffleModeEnabled.value = newPlayer.shuffleModeEnabled repeatMode.value = newPlayer.repeatMode Timber.tag(TAG).d("Attached to new player instance: $newPlayer") } fun playQueue(queue: Queue) { // Block if Listen Together guest (unless internal sync) if (!allowInternalSync && shouldBlockPlaybackChanges?.invoke() == true) { Timber.tag("PlayerConnection").d("playQueue blocked - Listen Together guest") return } if (!playerReadinessFlow.value) { Timber.tag(TAG).w("playQueue called before player ready; delegating to service") } try { service.playQueue(queue) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in playQueue") throw e } } fun startRadioSeamlessly() { // Block if Listen Together guest if (shouldBlockPlaybackChanges?.invoke() == true) { Timber.tag("PlayerConnection").d("startRadioSeamlessly blocked - Listen Together guest") return } if (!playerReadinessFlow.value) { Timber.tag(TAG).w("startRadioSeamlessly called before player ready; delegating to service") } try { service.startRadioSeamlessly() } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in startRadioSeamlessly") throw e } } fun playNext(item: MediaItem) = playNext(listOf(item)) fun playNext(items: List) { // Block if Listen Together guest (unless internal sync) if (!allowInternalSync && shouldBlockPlaybackChanges?.invoke() == true) { Timber.tag("PlayerConnection").d("playNext blocked - Listen Together guest") return } try { service.playNext(items) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in playNext") throw e } } fun addToQueue(item: MediaItem) = addToQueue(listOf(item)) fun addToQueue(items: List) { // Block if Listen Together guest (unless internal sync) if (!allowInternalSync && shouldBlockPlaybackChanges?.invoke() == true) { Timber.tag("PlayerConnection").d("addToQueue blocked - Listen Together guest") return } try { service.addToQueue(items) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in addToQueue") throw e } } fun toggleLike() { try { service.toggleLike() } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in toggleLike") } } fun toggleMute() { service.toggleMute() } fun setMuted(muted: Boolean) { service.setMuted(muted) } fun toggleLibrary() { try { service.toggleLibrary() } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in toggleLibrary") } } /** * Toggle play/pause - handles Cast when active */ fun togglePlayPause() { if (!allowInternalSync && shouldBlockPlaybackChanges?.invoke() == true) return try { val castHandler = service.castConnectionHandler if (castHandler?.isCasting?.value == true) { if (castHandler.castIsPlaying.value) { castHandler.pause() } else { castHandler.play() } } else { player.togglePlayPause() } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in togglePlayPause") } } /** * Start playback - handles Cast when active */ fun play() { try { val castHandler = service.castConnectionHandler if (castHandler?.isCasting?.value == true) { castHandler.play() } else { if (player.playbackState == Player.STATE_IDLE) { player.prepare() } player.playWhenReady = true } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in play") } } /** * Pause playback - handles Cast when active */ fun pause() { try { val castHandler = service.castConnectionHandler if (castHandler?.isCasting?.value == true) { castHandler.pause() } else { player.playWhenReady = false } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in pause") } } /** * Seek to position - handles Cast when active */ fun seekTo(position: Long) { try { val castHandler = service.castConnectionHandler if (castHandler?.isCasting?.value == true) { castHandler.seekTo(position) } else { player.seekTo(position) } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in seekTo") } } fun seekToNext() { try { // When casting, use Cast skip instead of local player val castHandler = service.castConnectionHandler if (castHandler?.isCasting?.value == true) { castHandler.skipToNext() return } player.seekToNext() if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) { player.prepare() } player.playWhenReady = true onSkipNext?.invoke() } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in seekToNext") } } var onRestartSong: (() -> Unit)? = null fun seekToPrevious() { try { // When casting, use Cast skip instead of local player val castHandler = service.castConnectionHandler if (castHandler?.isCasting?.value == true) { castHandler.skipToPrevious() return } // Logic to mimic standard seekToPrevious behavior but with explicit callbacks // If we are more than 3 seconds in, just restart the song if (player.currentPosition > 3000 || !player.hasPreviousMediaItem()) { player.seekTo(0) if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) { player.prepare() } player.playWhenReady = true onRestartSong?.invoke() } else { // Otherwise go to previous media item player.seekToPreviousMediaItem() if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) { player.prepare() } player.playWhenReady = true onSkipPrevious?.invoke() } } catch (e: Exception) { Timber.tag(TAG).e(e, "Error in seekToPrevious") } } /** Parses "0=09:00-23:00;1=22:00-06:00" into Map>. */ private fun parseDayTimes(raw: String): Map> { if (raw.isBlank()) return emptyMap() return raw .split(";") .mapNotNull { entry -> val parts = entry.split("=") if (parts.size != 2) return@mapNotNull null val dayIndex = parts[0].toIntOrNull() ?: return@mapNotNull null val times = parts[1].split("-") if (times.size != 2) return@mapNotNull null dayIndex to (times[0] to times[1]) }.toMap() } private fun checkAndStartAutomaticSleepTimer(): Boolean { return try { val sleepTimerEnabled = service.applicationContext.dataStore.get(SleepTimerEnabledKey) ?: false Timber.tag(TAG).d("✓ Sleep Timer Check: enabled=$sleepTimerEnabled") if (!sleepTimerEnabled) { Timber.tag(TAG).d("✗ Sleep Timer disabled - skipping") return false } if (service.sleepTimer.isActive) { Timber.tag(TAG).d("✗ Sleep Timer already active - skipping") return false } val sleepTimerRepeat = service.applicationContext.dataStore.get(SleepTimerRepeatKey) ?: "daily" val sleepTimerStartTime = service.applicationContext.dataStore.get(SleepTimerStartTimeKey) ?: "09:00" val sleepTimerEndTime = service.applicationContext.dataStore.get(SleepTimerEndTimeKey) ?: "23:00" val sleepTimerDefaultMinutes = (service.applicationContext.dataStore.get(SleepTimerDefaultKey) ?: 30f).roundToInt() val sleepTimerCustomDaysStr = service.applicationContext.dataStore.get(SleepTimerCustomDaysKey) ?: "0,1,2,3,4" val sleepTimerDayTimesStr = service.applicationContext.dataStore.get(SleepTimerDayTimesKey) ?: "" Timber .tag( TAG, ).d( "Sleep Timer Config: repeat=$sleepTimerRepeat start=$sleepTimerStartTime end=$sleepTimerEndTime default=$sleepTimerDefaultMinutes custom=$sleepTimerCustomDaysStr", ) val currentTime = LocalTime.now() val today = LocalDate.now() val dayOfWeek = today.dayOfWeek.value % 7 val adjustedDayOfWeek = if (dayOfWeek == 0) 6 else dayOfWeek - 1 Timber.tag(TAG).d("Current: time=$currentTime dayOfWeek=$adjustedDayOfWeek") val isDayAllowed = when (sleepTimerRepeat) { "daily" -> { true } "weekdays" -> { adjustedDayOfWeek in 0..4 } "weekends" -> { adjustedDayOfWeek in 5..6 } "weekdays_weekends" -> { true } // both groups active; per-day time handles the distinction "custom" -> { val customDays = sleepTimerCustomDaysStr.split(",").mapNotNull { it.trim().toIntOrNull() } Timber.tag(TAG).d("Custom days: $customDays, adjustedDayOfWeek=$adjustedDayOfWeek") adjustedDayOfWeek in customDays } else -> { false } } if (!isDayAllowed) { Timber.tag(TAG).d("✗ Day not allowed for Sleep Timer") return false } // "daily" uses the single global time window. // All other modes store per-day times in the dayTimes map so that // e.g. weekdays and weekends can have different windows. val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") val usesDayTimesMap = sleepTimerRepeat != "daily" val (startStr, endStr) = if (usesDayTimesMap) { parseDayTimes(sleepTimerDayTimesStr)[adjustedDayOfWeek] ?: (sleepTimerStartTime to sleepTimerEndTime) } else { sleepTimerStartTime to sleepTimerEndTime } val startTime = LocalTime.parse(startStr, timeFormatter) val endTime = LocalTime.parse(endStr, timeFormatter) // Support overnight ranges (e.g. 22:00–06:00) in addition to normal ranges val isTimeInRange = if (endTime.isAfter(startTime)) { currentTime.isAfter(startTime) && currentTime.isBefore(endTime) } else { currentTime.isAfter(startTime) || currentTime.isBefore(endTime) } Timber.tag(TAG).d("Time check: $currentTime between $startStr-$endStr? $isTimeInRange") if (isTimeInRange) { Timber.tag(TAG).i("AUTO SLEEP TIMER STARTED: $sleepTimerDefaultMinutes minutes") service.sleepTimer.start(sleepTimerDefaultMinutes) return true } Timber.tag(TAG).d("✗ Time not in range") return false } catch (e: Exception) { Timber.tag(TAG).e(e, "Sleep Timer error") return false } } override fun onPlaybackStateChanged(state: Int) { playbackState.value = state error.value = player.playerError } override fun onPlayWhenReadyChanged( newPlayWhenReady: Boolean, reason: Int, ) { val wasPlaying = playWhenReady.value playWhenReady.value = newPlayWhenReady // Central sleep timer trigger: fires on every paused -> playing transition, if (newPlayWhenReady && !wasPlaying) { checkAndStartAutomaticSleepTimer() } } override fun onMediaItemTransition( mediaItem: MediaItem?, reason: Int, ) { mediaMetadata.value = mediaItem?.metadata currentMediaItemIndex.value = player.currentMediaItemIndex currentWindowIndex.value = player.getCurrentQueueIndex() updateCanSkipPreviousAndNext() } override fun onTimelineChanged( timeline: Timeline, reason: Int, ) { queueWindows.value = player.getQueueWindows() queueTitle.value = service.queueTitle currentMediaItemIndex.value = player.currentMediaItemIndex currentWindowIndex.value = player.getCurrentQueueIndex() updateCanSkipPreviousAndNext() } override fun onShuffleModeEnabledChanged(enabled: Boolean) { shuffleModeEnabled.value = enabled queueWindows.value = player.getQueueWindows() currentWindowIndex.value = player.getCurrentQueueIndex() updateCanSkipPreviousAndNext() } override fun onRepeatModeChanged(mode: Int) { repeatMode.value = mode updateCanSkipPreviousAndNext() } override fun onPlayerErrorChanged(playbackError: PlaybackException?) { if (playbackError != null) { reportException(playbackError) } error.value = playbackError } private fun updateCanSkipPreviousAndNext() { if (!player.currentTimeline.isEmpty) { val window = player.currentTimeline.getWindow(player.currentMediaItemIndex, Timeline.Window()) canSkipPrevious.value = player.isCommandAvailable(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) || !window.isLive || player.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) canSkipNext.value = window.isLive && window.isDynamic || player.isCommandAvailable(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) } else { canSkipPrevious.value = false canSkipNext.value = false } } fun dispose() { try { attachedPlayer?.removeListener(this) attachedPlayer = null Timber.tag(TAG).d("PlayerConnection disposed successfully") } catch (e: Exception) { Timber.tag(TAG).e(e, "Error during PlayerConnection disposal") } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback import androidx.media3.common.C import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf 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, var player: Player, private val onVolumeMultiplierChanged: (Float) -> Unit = {}, ) : Player.Listener { private companion object { private const val TIMER_TICK_MS = 1000L private const val FADE_OUT_WINDOW_MS = 60_000L } private var sleepTimerJob: Job? = null var triggerTime by mutableLongStateOf(-1L) private set var pauseWhenSongEnd by mutableStateOf(false) private set var stopAfterCurrentSongOnTimeout by mutableStateOf(false) private set var fadeOutEnabled by mutableStateOf(false) private set val isActive: Boolean get() = triggerTime != -1L || pauseWhenSongEnd fun start(minute: Int) { start( minute = minute, stopAfterCurrentSong = false, fadeOut = false, ) } fun start( minute: Int, stopAfterCurrentSong: Boolean, fadeOut: Boolean, ) { sleepTimerJob?.cancel() sleepTimerJob = null updateVolumeMultiplier(1f) fadeOutEnabled = fadeOut if (minute == -1) { pauseWhenSongEnd = true stopAfterCurrentSongOnTimeout = false triggerTime = -1L if (fadeOutEnabled) { sleepTimerJob = scope.launch { while (this@SleepTimer.isActive) { updateVolumeMultiplierForCurrentSong() delay(TIMER_TICK_MS) } } } } else { pauseWhenSongEnd = false stopAfterCurrentSongOnTimeout = stopAfterCurrentSong triggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds sleepTimerJob = scope.launch { while (this@SleepTimer.isActive) { if (triggerTime != -1L) { val remainingMs = triggerTime - System.currentTimeMillis() if (remainingMs <= 0L) { triggerTime = -1L if (stopAfterCurrentSongOnTimeout) { pauseWhenSongEnd = true stopAfterCurrentSongOnTimeout = false if (!fadeOutEnabled) { break } } else { completeTimerAndPause() break } } else if (fadeOutEnabled && !stopAfterCurrentSongOnTimeout) { updateVolumeMultiplierForRemainingTime(remainingMs) } } else if (pauseWhenSongEnd && fadeOutEnabled) { updateVolumeMultiplierForCurrentSong() } delay(TIMER_TICK_MS) } } } } /** * Notify the sleep timer that a song transition has occurred outside of normal * player callbacks (e.g. during crossfade player swap). If "end of song" mode * is active, this will pause the player and deactivate the timer. */ fun notifySongTransition() { if (pauseWhenSongEnd) { completeTimerAndPause() } } fun clear() { sleepTimerJob?.cancel() sleepTimerJob = null pauseWhenSongEnd = false stopAfterCurrentSongOnTimeout = false fadeOutEnabled = false triggerTime = -1L updateVolumeMultiplier(1f) } override fun onMediaItemTransition( mediaItem: MediaItem?, reason: Int, ) { if (pauseWhenSongEnd) { completeTimerAndPause() } } override fun onPlaybackStateChanged( @Player.State playbackState: Int, ) { if (playbackState == Player.STATE_ENDED && pauseWhenSongEnd) { completeTimerAndPause() } } private fun completeTimerAndPause() { sleepTimerJob?.cancel() sleepTimerJob = null pauseWhenSongEnd = false stopAfterCurrentSongOnTimeout = false fadeOutEnabled = false triggerTime = -1L updateVolumeMultiplier(1f) player.pause() } private fun updateVolumeMultiplierForRemainingTime(remainingMs: Long) { updateVolumeMultiplier(volumeMultiplierForRemainingTime(remainingMs)) } private fun updateVolumeMultiplierForCurrentSong() { val duration = player.duration if (duration == C.TIME_UNSET || duration <= 0) { updateVolumeMultiplier(1f) return } val remainingMs = (duration - player.currentPosition).coerceAtLeast(0L) updateVolumeMultiplierForRemainingTime(remainingMs) } private fun volumeMultiplierForRemainingTime(remainingMs: Long): Float { if (remainingMs >= FADE_OUT_WINDOW_MS) return 1f return (remainingMs.toFloat() / FADE_OUT_WINDOW_MS).coerceIn(0f, 1f) } private fun updateVolumeMultiplier(multiplier: Float) { onVolumeMultiplierChanged(multiplier) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/alarm/MusicAlarmReceiver.kt ================================================ package com.metrolist.music.playback.alarm import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.core.content.ContextCompat import com.metrolist.music.playback.MusicService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class MusicAlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { if (intent?.action != ACTION_TRIGGER_ALARM) return val pendingResult = goAsync() val alarmId = intent.getStringExtra(MusicService.EXTRA_ALARM_ID).orEmpty() val playlistId = intent.getStringExtra(MusicService.EXTRA_ALARM_PLAYLIST_ID).orEmpty() val randomSong = intent.getBooleanExtra(MusicService.EXTRA_ALARM_RANDOM_SONG, false) val serviceIntent = Intent(context, MusicService::class.java) .setAction(MusicService.ACTION_ALARM_TRIGGER) .putExtra(MusicService.EXTRA_ALARM_ID, alarmId) .putExtra(MusicService.EXTRA_ALARM_PLAYLIST_ID, playlistId) .putExtra(MusicService.EXTRA_ALARM_RANDOM_SONG, randomSong) ContextCompat.startForegroundService(context, serviceIntent) CoroutineScope(Dispatchers.IO).launch { try { MusicAlarmScheduler.scheduleFromPreferences(context) } finally { pendingResult.finish() } } } companion object { const val ACTION_TRIGGER_ALARM = "com.metrolist.music.action.TRIGGER_ALARM" } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/alarm/MusicAlarmRescheduleReceiver.kt ================================================ package com.metrolist.music.playback.alarm import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class MusicAlarmRescheduleReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { when (intent?.action) { Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> { val pendingResult = goAsync() CoroutineScope(Dispatchers.IO).launch { try { MusicAlarmScheduler.scheduleFromPreferences(context) } finally { pendingResult.finish() } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/alarm/MusicAlarmScheduler.kt ================================================ package com.metrolist.music.playback.alarm import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build import com.metrolist.music.playback.MusicService import java.util.Calendar object MusicAlarmScheduler { fun scheduleFromPreferences(context: Context) { val alarms = MusicAlarmStore.loadBlocking(context) scheduleAll(context, alarms) } fun scheduleAll(context: Context, alarms: List) { val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return val knownAlarmIds = (MusicAlarmStore.loadBlocking(context).map { it.id } + alarms.map { it.id }).distinct() knownAlarmIds.forEach { alarmId -> cancel(context, alarmId) } val updated = alarms.map { alarm -> if (!alarm.enabled || alarm.playlistId.isBlank()) { alarm.copy(nextTriggerAt = -1L) } else { val triggerAtMillis = nextTriggerMillis(alarm.hour, alarm.minute) val pendingIntent = alarmPendingIntent(context, alarm) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) } else { alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) } alarm.copy(nextTriggerAt = triggerAtMillis) } } MusicAlarmStore.saveBlocking(context, updated) } fun cancel(context: Context, alarmId: String) { val alarmManager = context.getSystemService(AlarmManager::class.java) ?: return alarmManager.cancel(alarmPendingIntent(context, alarmId)) alarmManager.cancel(legacyAlarmPendingIntent(context, alarmId)) } private fun alarmPendingIntent( context: Context, alarm: MusicAlarmEntry ): PendingIntent { val intent = Intent(context, MusicService::class.java) .setAction(MusicService.ACTION_ALARM_TRIGGER) .putExtra(MusicService.EXTRA_ALARM_ID, alarm.id) .putExtra(MusicService.EXTRA_ALARM_PLAYLIST_ID, alarm.playlistId) .putExtra(MusicService.EXTRA_ALARM_RANDOM_SONG, alarm.randomSong) return foregroundServicePendingIntent(context, alarm.id, intent) } private fun alarmPendingIntent(context: Context, alarmId: String): PendingIntent { val intent = Intent(context, MusicService::class.java) .setAction(MusicService.ACTION_ALARM_TRIGGER) .putExtra(MusicService.EXTRA_ALARM_ID, alarmId) return foregroundServicePendingIntent(context, alarmId, intent) } private fun foregroundServicePendingIntent( context: Context, alarmId: String, intent: Intent ): PendingIntent { return PendingIntent.getForegroundService( context, requestCode(alarmId), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun legacyAlarmPendingIntent(context: Context, alarmId: String): PendingIntent { val intent = Intent(context, MusicAlarmReceiver::class.java) .setAction(MusicAlarmReceiver.ACTION_TRIGGER_ALARM) .putExtra(MusicService.EXTRA_ALARM_ID, alarmId) return PendingIntent.getBroadcast( context, requestCode(alarmId), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun requestCode(alarmId: String): Int { return alarmId.hashCode() and Int.MAX_VALUE } private fun nextTriggerMillis(hour: Int, minute: Int): Long { val calendar = Calendar.getInstance().apply { set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) set(Calendar.HOUR_OF_DAY, hour) set(Calendar.MINUTE, minute) } if (calendar.timeInMillis <= System.currentTimeMillis()) { calendar.add(Calendar.DAY_OF_YEAR, 1) } return calendar.timeInMillis } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/alarm/MusicAlarmStore.kt ================================================ package com.metrolist.music.playback.alarm import android.content.Context import android.os.Build import androidx.datastore.preferences.core.edit import com.metrolist.music.constants.AlarmEnabledKey import com.metrolist.music.constants.AlarmEntriesKey import com.metrolist.music.constants.AlarmHourKey import com.metrolist.music.constants.AlarmMinuteKey import com.metrolist.music.constants.AlarmNextTriggerAtKey import com.metrolist.music.constants.AlarmPlaylistIdKey import com.metrolist.music.constants.AlarmRandomSongKey import com.metrolist.music.utils.dataStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.json.JSONArray import org.json.JSONObject import java.util.UUID data class MusicAlarmEntry( val id: String, val enabled: Boolean, val hour: Int, val minute: Int, val playlistId: String, val randomSong: Boolean, val nextTriggerAt: Long = -1L ) object MusicAlarmStore { private const val ALARM_PREFS = "alarm_store" private const val ALARM_PREFS_ENTRIES = "alarm_entries" suspend fun load(context: Context): List { val protectedRaw = alarmPrefsContext(context).getSharedPreferences(ALARM_PREFS, Context.MODE_PRIVATE) .getString(ALARM_PREFS_ENTRIES, null) .orEmpty() if (protectedRaw.isNotBlank()) { parse(protectedRaw)?.let { return it } } return runCatching { val prefs = context.dataStore.data.first() val raw = prefs[AlarmEntriesKey].orEmpty() if (raw.isNotBlank()) { parse(raw) ?: migrateLegacy( prefs[AlarmEnabledKey] ?: false, prefs[AlarmHourKey] ?: 7, prefs[AlarmMinuteKey] ?: 0, prefs[AlarmPlaylistIdKey].orEmpty(), prefs[AlarmRandomSongKey] ?: false, prefs[AlarmNextTriggerAtKey] ?: -1L ) } else { migrateLegacy(prefs[AlarmEnabledKey] ?: false, prefs[AlarmHourKey] ?: 7, prefs[AlarmMinuteKey] ?: 0, prefs[AlarmPlaylistIdKey].orEmpty(), prefs[AlarmRandomSongKey] ?: false, prefs[AlarmNextTriggerAtKey] ?: -1L) } }.getOrElse { emptyList() }.also { entries -> if (entries.isNotEmpty()) { saveProtected(context, entries) } } } fun loadBlocking(context: Context): List { return runBlocking { load(context) } } suspend fun save(context: Context, entries: List) { saveProtected(context, entries) context.dataStore.edit { prefs -> prefs[AlarmEntriesKey] = serialize(entries) prefs[AlarmNextTriggerAtKey] = entries.filter { it.enabled }.minOfOrNull { it.nextTriggerAt.takeIf { time -> time > 0L } ?: Long.MAX_VALUE } ?.takeIf { it != Long.MAX_VALUE } ?: -1L } } fun saveBlocking(context: Context, entries: List) { runBlocking { save(context, entries) } } fun createEmpty(): MusicAlarmEntry { return MusicAlarmEntry( id = UUID.randomUUID().toString(), enabled = true, hour = 7, minute = 0, playlistId = "", randomSong = false, nextTriggerAt = -1L ) } private fun migrateLegacy( enabled: Boolean, hour: Int, minute: Int, playlistId: String, randomSong: Boolean, nextTriggerAt: Long ): List { if (playlistId.isBlank()) return emptyList() return listOf( MusicAlarmEntry( id = "legacy-main-alarm", enabled = enabled, hour = hour, minute = minute, playlistId = playlistId, randomSong = randomSong, nextTriggerAt = nextTriggerAt ) ) } private fun serialize(entries: List): String { val array = JSONArray() entries.forEach { entry -> array.put( JSONObject() .put("id", entry.id) .put("enabled", entry.enabled) .put("hour", entry.hour) .put("minute", entry.minute) .put("playlistId", entry.playlistId) .put("randomSong", entry.randomSong) .put("nextTriggerAt", entry.nextTriggerAt) ) } return array.toString() } private fun parse(raw: String): List? { val array = runCatching { JSONArray(raw) }.getOrElse { return null } return buildList { for (index in 0 until array.length()) { val item = array.optJSONObject(index) ?: continue runCatching { MusicAlarmEntry( id = item.optString("id").ifBlank { UUID.randomUUID().toString() }, enabled = item.optBoolean("enabled", true), hour = item.optInt("hour", 7).coerceIn(0, 23), minute = item.optInt("minute", 0).coerceIn(0, 59), playlistId = item.optString("playlistId"), randomSong = item.optBoolean("randomSong", false), nextTriggerAt = item.optLong("nextTriggerAt", -1L) ) }.getOrNull()?.let(::add) } } } private fun saveProtected(context: Context, entries: List) { alarmPrefsContext(context) .getSharedPreferences(ALARM_PREFS, Context.MODE_PRIVATE) .edit() .putString(ALARM_PREFS_ENTRIES, serialize(entries)) .apply() } private fun alarmPrefsContext(context: Context): Context { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { context.createDeviceProtectedStorageContext() } else { context } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/audio/SilenceDetectorAudioProcessor.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback.audio import androidx.media3.common.C import androidx.media3.common.audio.AudioProcessor import androidx.media3.common.util.UnstableApi import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.abs /** * Lightweight PCM pass-through processor that detects long stretches of near-silence. * When [instantModeEnabled] is true and a silence block longer than [minSilenceDurationUs] * is detected, [onLongSilence] is invoked exactly once per silent segment. */ @UnstableApi @Suppress("DEPRECATION") class SilenceDetectorAudioProcessor( private val minSilenceDurationUs: Long = 2_000_000L, private val silenceThreshold: Int = 256, private val onLongSilence: () -> Unit, ) : AudioProcessor { private var sampleRate = 0 private var channelCount = 0 private var encoding = C.ENCODING_INVALID private var outputBuffer: ByteBuffer = EMPTY_BUFFER private var inputEnded = false @Volatile var instantModeEnabled: Boolean = false @Volatile private var consecutiveSilentFrames: Long = 0 @Volatile private var inSilence: Boolean = false private var notifiedThisSilence = false override fun configure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { sampleRate = inputAudioFormat.sampleRate channelCount = inputAudioFormat.channelCount encoding = inputAudioFormat.encoding if (encoding != C.ENCODING_PCM_16BIT) { throw AudioProcessor.UnhandledAudioFormatException(inputAudioFormat) } return inputAudioFormat } override fun isActive(): Boolean = true override fun queueInput(inputBuffer: ByteBuffer) { if (!inputBuffer.hasRemaining()) { outputBuffer = EMPTY_BUFFER return } // Analyze the incoming PCM for silence without mutating the buffer position. if (instantModeEnabled && sampleRate > 0 && channelCount > 0) { detectSilence(inputBuffer) } else { clearSilenceState() } val out = replaceOutputBuffer(inputBuffer.remaining()) out.put(inputBuffer) out.flip() } private fun detectSilence(inputBuffer: ByteBuffer) { // Ensure predictable endian access for getShort(index). inputBuffer.order(ByteOrder.LITTLE_ENDIAN) val frameCount = inputBuffer.remaining() / 2 / channelCount val basePosition = inputBuffer.position() repeat(frameCount) { frameIndex -> var framePeak = 0 repeat(channelCount) { channelIndex -> val sampleIndex = basePosition + (frameIndex * channelCount + channelIndex) * 2 val sampleValue = abs(inputBuffer.getShort(sampleIndex).toInt()) if (sampleValue > framePeak) { framePeak = sampleValue } } if (framePeak < silenceThreshold) { consecutiveSilentFrames++ val silentDurationUs = (consecutiveSilentFrames * 1_000_000L) / sampleRate if (silentDurationUs >= minSilenceDurationUs) { inSilence = true if (!notifiedThisSilence) { notifiedThisSilence = true onLongSilence() } } } else { clearSilenceState() } } } private fun clearSilenceState() { consecutiveSilentFrames = 0 inSilence = false notifiedThisSilence = false } fun resetTracking() { clearSilenceState() } fun isCurrentlySilent(): Boolean = inSilence override fun queueEndOfStream() { inputEnded = true } override fun getOutput(): ByteBuffer { val output = outputBuffer outputBuffer = EMPTY_BUFFER return output } override fun isEnded(): Boolean = inputEnded && outputBuffer === EMPTY_BUFFER @Deprecated("Deprecated in AudioProcessor") override fun flush() { outputBuffer = EMPTY_BUFFER inputEnded = false clearSilenceState() } @Deprecated("Deprecated in AudioProcessor") override fun reset() { flush() sampleRate = 0 channelCount = 0 encoding = C.ENCODING_INVALID } private fun replaceOutputBuffer(size: Int): ByteBuffer { if (outputBuffer.capacity() < size) { outputBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder()) } else { outputBuffer.clear() } return outputBuffer } companion object { private val EMPTY_BUFFER: ByteBuffer = ByteBuffer.allocateDirect(0).order(ByteOrder.nativeOrder()) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/queues/EmptyQueue.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback.queues import androidx.media3.common.MediaItem import com.metrolist.music.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() } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/queues/ListQueue.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback.queues import androidx.media3.common.MediaItem import com.metrolist.music.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() } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/queues/LocalAlbumRadio.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback.queues import androidx.media3.common.MediaItem import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.db.entities.AlbumWithSongs import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.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 ) 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() } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/queues/Queue.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback.queues import androidx.media3.common.MediaItem import com.metrolist.music.extensions.metadata import com.metrolist.music.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 filterVideoSongs(disableVideos: Boolean = false) = if (disableVideos) { copy( items = items.filterVideoSongs(true), ) } else { this } } } fun List.filterExplicit(enabled: Boolean = true) = if (enabled) { filterNot { it.metadata?.explicit == true } } else { this } fun List.filterVideoSongs(disableVideos: Boolean = false) = if (disableVideos) { filterNot { it.metadata?.isVideoSong == true } } else { this } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/queues/YouTubeAlbumRadio.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback.queues import androidx.media3.common.MediaItem import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.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 ) 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() } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/queues/YouTubePlaylistQueue.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback.queues import androidx.media3.common.MediaItem import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.SongItem import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.MediaMetadata import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.withContext class YouTubePlaylistQueue( private val playlistId: String, private val playlistTitle: String? = null, private val initialSongs: List = emptyList(), private val initialContinuation: String? = null, private val startIndex: Int = 0, override val preloadItem: MediaMetadata? = null, ) : Queue { private var continuation: String? = initialContinuation private var retryCount = 0 private val maxRetries = 3 override suspend fun getInitialStatus(): Queue.Status { return withContext(IO) { if (initialSongs.isNotEmpty()) { Queue.Status( title = playlistTitle, items = initialSongs.map { it.toMediaItem() }, mediaItemIndex = startIndex, ) } else { val playlistPage = YouTube.playlist(playlistId).getOrThrow() continuation = playlistPage.songsContinuation Queue.Status( title = playlistPage.playlist.title, items = playlistPage.songs.map { it.toMediaItem() }, mediaItemIndex = startIndex, ) } } } override fun hasNextPage(): Boolean = continuation != null override suspend fun nextPage(): List { return withContext(IO) { val currentContinuation = continuation ?: return@withContext emptyList() var lastException: Throwable? = null for (attempt in 0..maxRetries) { try { val continuationPage = YouTube.playlistContinuation(currentContinuation).getOrThrow() continuation = continuationPage.continuation retryCount = 0 return@withContext continuationPage.songs.map { it.toMediaItem() } } catch (e: Exception) { lastException = e retryCount++ if (retryCount >= maxRetries) { continuation = null } } } throw lastException ?: Exception("Failed to get next page") } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/playback/queues/YouTubeQueue.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.playback.queues import androidx.media3.common.MediaItem import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.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 private var retryCount = 0 private val maxRetries = 3 override suspend fun getInitialStatus(): Queue.Status { return withContext(IO) { var lastException: Throwable? = null if (endpoint.videoId != null && endpoint.playlistId == null) { endpoint = WatchEndpoint( videoId = endpoint.videoId, playlistId = "RDAMVM${endpoint.videoId}" ) } for (attempt in 0..maxRetries) { try { val nextResult = YouTube.next(endpoint, continuation).getOrThrow() endpoint = nextResult.endpoint continuation = nextResult.continuation retryCount = 0 return@withContext Queue.Status( title = nextResult.title, items = nextResult.items.map { it.toMediaItem() }, mediaItemIndex = nextResult.currentIndex ?: 0, ) } catch (e: Exception) { lastException = e } } throw lastException ?: Exception("Failed to get initial status") } } override fun hasNextPage(): Boolean = continuation != null override suspend fun nextPage(): List { return withContext(IO) { var lastException: Throwable? = null for (attempt in 0..maxRetries) { try { val nextResult = YouTube.next(endpoint, continuation).getOrThrow() endpoint = nextResult.endpoint continuation = nextResult.continuation retryCount = 0 return@withContext nextResult.items.map { it.toMediaItem() } } catch (e: Exception) { lastException = e retryCount++ if (retryCount >= maxRetries) { continuation = null // Stop trying to load more } } } throw lastException ?: Exception("Failed to get next page") } } companion object { /** * Creates a radio queue based on a song. * Explicitly requests the RDAMVM playlist to trigger automotive/radio mixing. */ fun radio(song: MediaMetadata): YouTubeQueue { return YouTubeQueue( WatchEndpoint( videoId = song.id, playlistId = "RDAMVM${song.id}" ), song ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/quicksettings/MusicRecognizerTileService.kt ================================================ package com.metrolist.music.quicksettings import android.app.PendingIntent import android.content.Intent import android.graphics.drawable.Icon import android.os.Build import android.service.quicksettings.Tile import android.service.quicksettings.TileService import com.metrolist.music.R import com.metrolist.music.recognition.RecognitionLaunchActivity class MusicRecognizerTileService : TileService() { override fun onStartListening() { super.onStartListening() qsTile?.apply { icon = Icon.createWithResource(this@MusicRecognizerTileService, R.drawable.mic) state = Tile.STATE_INACTIVE updateTile() } } override fun onClick() { super.onClick() val launchIntent = Intent(this, RecognitionLaunchActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val pendingIntent = PendingIntent.getActivity( this, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) startActivityAndCollapse(pendingIntent) } else { @Suppress("DEPRECATION") startActivityAndCollapse(launchIntent) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/recognition/AudioResampler.kt ================================================ package com.metrolist.music.recognition import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.common.audio.AudioProcessor import androidx.media3.common.audio.SonicAudioProcessor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import java.nio.ByteBuffer import java.nio.ByteOrder /** * Data class representing decoded audio data with its properties. */ data class DecodedAudio( val data: ByteArray, val channelCount: Int, val sampleRate: Int, val pcmEncoding: Int, ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as DecodedAudio return data.contentEquals(other.data) && channelCount == other.channelCount && sampleRate == other.sampleRate && pcmEncoding == other.pcmEncoding } override fun hashCode(): Int { var result = data.contentHashCode() result = 31 * result + channelCount result = 31 * result + sampleRate result = 31 * result + pcmEncoding return result } } /** * Audio resampler using Media3 SonicAudioProcessor. * Resamples audio to the required sample rate for fingerprinting. */ @OptIn(UnstableApi::class) object AudioResampler { suspend fun resample( decodedAudio: DecodedAudio, outputSampleRate: Int ): Result = withContext(Dispatchers.Default) { if (decodedAudio.sampleRate == outputSampleRate) { return@withContext Result.success(decodedAudio) } var sonicRef: AudioProcessor? = null try { val sonic: AudioProcessor = SonicAudioProcessor().apply { setOutputSampleRateHz(outputSampleRate) } sonicRef = sonic val inputFormat = AudioProcessor.AudioFormat( decodedAudio.sampleRate, decodedAudio.channelCount, decodedAudio.pcmEncoding ) val outputFormat = sonic.configure(inputFormat) sonic.flush() val inputBuf = ByteBuffer.wrap(decodedAudio.data).order(ByteOrder.nativeOrder()) sonic.queueInput(inputBuf) sonic.queueEndOfStream() val outputChunks = mutableListOf() var outputChunksByteSize = 0 while (!sonic.isEnded) { ensureActive() val outputBuffer = sonic.output if (!outputBuffer.hasRemaining()) continue val chunk = ByteArray(outputBuffer.remaining()) outputBuffer.get(chunk) outputChunks.add(chunk) outputChunksByteSize += chunk.size } sonic.reset() val resampledData = if (outputChunks.size == 1) { outputChunks[0] } else { ByteArray(outputChunksByteSize).also { var dest = 0 for (chunk in outputChunks) { System.arraycopy(chunk, 0, it, dest, chunk.size) dest += chunk.size } } } Result.success(DecodedAudio( data = resampledData, channelCount = outputFormat.channelCount, sampleRate = outputFormat.sampleRate, pcmEncoding = outputFormat.encoding, )) } catch (e: Exception) { ensureActive() Result.failure(e) } finally { sonicRef?.reset() } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/recognition/MusicRecognitionService.kt ================================================ /** * Music Recognition Feature * * This feature is based on the original MusicRecognizer project by Aleksey Saenko. * Original project: https://github.com/aleksey-saenko/MusicRecognizer * * Special thanks to Aleksey Saenko for the music recognition implementation. */ package com.metrolist.music.recognition import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import androidx.core.content.ContextCompat import com.metrolist.shazamkit.Shazam import com.metrolist.shazamkit.models.RecognitionResult import com.metrolist.shazamkit.models.RecognitionStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream import java.nio.ByteOrder /** * Service for recognizing music using audio fingerprinting. * Records audio from the microphone, generates a Shazam-compatible fingerprint, * and sends it to the Shazam API for recognition. */ object MusicRecognitionService { // Recording parameters private const val RECORDING_SAMPLE_RATE = 44100 private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT // Recording duration: 12 seconds for better recognition accuracy // We use 12s directly to match the fallback duration for maximum compatibility private const val RECORDING_DURATION_MS = 12000L private val _recognitionStatus = MutableStateFlow(RecognitionStatus.Ready) val recognitionStatus: StateFlow = _recognitionStatus.asStateFlow() /** * Set to true by the widget service after it has already persisted the result to the * database, so that [RecognitionScreen] skips the duplicate insert. * Reset to false by [reset]. */ var resultSavedExternally: Boolean = false fun hasRecordPermission(context: Context): Boolean { return ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO ) == PackageManager.PERMISSION_GRANTED } /** * Start the music recognition process. * Records audio, generates fingerprint, and queries Shazam API. */ @SuppressLint("MissingPermission") suspend fun recognize(context: Context): RecognitionStatus = withContext(Dispatchers.IO) { if (!hasRecordPermission(context)) { return@withContext RecognitionStatus.Error("Microphone permission not granted") } _recognitionStatus.value = RecognitionStatus.Listening try { // Step 1: Record audio val audioData = recordAudio() _recognitionStatus.value = RecognitionStatus.Processing // Step 2: Convert to mono if needed and resample to 16kHz val decodedAudio = DecodedAudio( data = audioData, channelCount = 1, sampleRate = RECORDING_SAMPLE_RATE, pcmEncoding = AUDIO_FORMAT ) val resampledAudio = AudioResampler.resample( decodedAudio, VibraSignature.REQUIRED_SAMPLE_RATE ).getOrElse { error -> _recognitionStatus.value = RecognitionStatus.Error("Failed to resample audio: ${error.message}") return@withContext _recognitionStatus.value } // Verify format require( resampledAudio.channelCount == 1 && resampledAudio.sampleRate == VibraSignature.REQUIRED_SAMPLE_RATE && resampledAudio.pcmEncoding == AudioFormat.ENCODING_PCM_16BIT && ByteOrder.nativeOrder() == ByteOrder.LITTLE_ENDIAN && resampledAudio.data.isNotEmpty() && resampledAudio.data.size % 2 == 0 ) { "Invalid audio format for fingerprint generation" } // Step 3: Generate fingerprint using native library val signature = try { VibraSignature.fromI16(resampledAudio.data) } catch (e: Exception) { _recognitionStatus.value = RecognitionStatus.Error("Failed to generate fingerprint: ${e.message}") return@withContext _recognitionStatus.value } // Step 4: Send to Shazam API val sampleDurationMs = (resampledAudio.data.size / 2) * 1000L / VibraSignature.REQUIRED_SAMPLE_RATE val result = Shazam.recognize(signature, sampleDurationMs) result.fold( onSuccess = { recognitionResult -> _recognitionStatus.value = RecognitionStatus.Success(recognitionResult) }, onFailure = { error -> val message = error.message ?: "Unknown error" _recognitionStatus.value = if (message.contains("No match", ignoreCase = true)) { RecognitionStatus.NoMatch("No matches found. Try again with clearer audio.") } else { RecognitionStatus.Error(message) } } ) _recognitionStatus.value } catch (e: Exception) { _recognitionStatus.value = RecognitionStatus.Error(e.message ?: "Recognition failed") _recognitionStatus.value } } @SuppressLint("MissingPermission") private suspend fun recordAudio(): ByteArray = withContext(Dispatchers.IO) { val bufferSize = AudioRecord.getMinBufferSize( RECORDING_SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT ) val audioRecord = AudioRecord( MediaRecorder.AudioSource.MIC, RECORDING_SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, bufferSize ) val outputStream = ByteArrayOutputStream() val buffer = ByteArray(bufferSize) val startTime = System.currentTimeMillis() try { audioRecord.startRecording() while (System.currentTimeMillis() - startTime < RECORDING_DURATION_MS && isActive) { val bytesRead = audioRecord.read(buffer, 0, bufferSize) if (bytesRead > 0) { outputStream.write(buffer, 0, bytesRead) } } } finally { audioRecord.stop() audioRecord.release() } outputStream.toByteArray() } fun reset() { _recognitionStatus.value = RecognitionStatus.Ready resultSavedExternally = false } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/recognition/RecognitionForegroundService.kt ================================================ package com.metrolist.music.recognition import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.graphics.Bitmap import android.graphics.BitmapFactory import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.metrolist.music.MainActivity import com.metrolist.music.R import com.metrolist.shazamkit.models.RecognitionResult import com.metrolist.shazamkit.models.RecognitionStatus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import java.net.HttpURLConnection import java.net.URL class RecognitionForegroundService : Service() { private val serviceScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) private var recognitionJob: Job? = null private var statusJob: Job? = null private var keepNotificationOnStop = false private var terminalStateHandled = false override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { createNotificationChannel() } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (!startInForeground()) return START_NOT_STICKY startRecognitionIfNeeded() return START_NOT_STICKY } override fun onDestroy() { recognitionJob?.cancel() statusJob?.cancel() serviceScope.cancel() if (!keepNotificationOnStop) { stopForeground(STOP_FOREGROUND_REMOVE) } super.onDestroy() } private fun startInForeground(): Boolean { val notification = buildNotification( title = getString(R.string.recognize_music), contentText = getString(R.string.recognition_notification_listening), isTerminal = false, contentIntent = null, largeIcon = null, actionIntent = null, actionTitle = null, ) try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, ) } else { startForeground(NOTIFICATION_ID, notification) } return true } catch (foregroundTypeException: SecurityException) { Log.w(TAG, "Unable to start microphone foreground service", foregroundTypeException) stopSelf() return false } catch (runtimeException: RuntimeException) { if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && runtimeException::class.java.name == "android.app.ForegroundServiceStartNotAllowedException" ) { Log.w(TAG, "Unable to start microphone foreground service", runtimeException) stopSelf() return false } throw runtimeException } } private fun startRecognitionIfNeeded() { if (recognitionJob?.isActive == true) return keepNotificationOnStop = false terminalStateHandled = false MusicRecognitionService.reset() statusJob?.cancel() statusJob = serviceScope.launch { MusicRecognitionService.recognitionStatus.collect { status -> when (status) { is RecognitionStatus.Ready -> Unit else -> renderStatus(status) } } } recognitionJob = serviceScope.launch { val result = MusicRecognitionService.recognize(this@RecognitionForegroundService) if (result is RecognitionStatus.Error && MusicRecognitionService.recognitionStatus.value !is RecognitionStatus.Error ) { renderStatus(result) } } } private fun renderStatus(status: RecognitionStatus) { when (status) { is RecognitionStatus.Listening -> { updateNotification( title = getString(R.string.recognize_music), contentText = getString(R.string.recognition_notification_listening), isTerminal = false, contentIntent = null, largeIcon = null, actionIntent = null, actionTitle = null, ) } is RecognitionStatus.Processing -> { updateNotification( title = getString(R.string.recognize_music), contentText = getString(R.string.recognition_notification_processing), isTerminal = false, contentIntent = null, largeIcon = null, actionIntent = null, actionTitle = null, ) } is RecognitionStatus.Success -> { handleSuccess(status.result) } is RecognitionStatus.NoMatch -> { if (terminalStateHandled) return terminalStateHandled = true updateNotification( title = getString(R.string.recognize_music), contentText = getString(R.string.recognition_notification_no_match), isTerminal = true, contentIntent = null, largeIcon = null, actionIntent = null, actionTitle = null, ) finishWithPersistentResult() } is RecognitionStatus.Error -> { if (terminalStateHandled) return terminalStateHandled = true updateNotification( title = getString(R.string.recognize_music), contentText = getString(R.string.recognition_notification_failed), isTerminal = true, contentIntent = null, largeIcon = null, actionIntent = null, actionTitle = null, ) finishWithPersistentResult() } is RecognitionStatus.Ready -> Unit } } private fun updateNotification( title: String, contentText: String, isTerminal: Boolean, contentIntent: PendingIntent?, largeIcon: Bitmap?, actionIntent: PendingIntent?, actionTitle: String?, ) { NotificationManagerCompat.from(this).notify( NOTIFICATION_ID, buildNotification( title = title, contentText = contentText, isTerminal = isTerminal, contentIntent = contentIntent, largeIcon = largeIcon, actionIntent = actionIntent, actionTitle = actionTitle, ), ) } private fun buildNotification( title: String, contentText: String, isTerminal: Boolean, contentIntent: PendingIntent?, largeIcon: Bitmap?, actionIntent: PendingIntent?, actionTitle: String?, ) = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_widget_mic) .setContentTitle(title) .setContentText(contentText) .setPriority(NotificationCompat.PRIORITY_LOW) .setOnlyAlertOnce(true) .setOngoing(!isTerminal) .setAutoCancel(isTerminal) .setContentIntent(contentIntent) .setLargeIcon(largeIcon) .apply { if (actionIntent != null && actionTitle != null) { addAction(0, actionTitle, actionIntent) } } .build() private fun handleSuccess(result: RecognitionResult) { if (terminalStateHandled) return terminalStateHandled = true val pendingIntent = createResultPendingIntent(result) updateNotification( title = result.title, contentText = result.artist, isTerminal = true, contentIntent = pendingIntent, largeIcon = null, actionIntent = pendingIntent, actionTitle = getString(R.string.listen_on_metrolist), ) serviceScope.launch { val coverUrl = result.coverArtHqUrl ?: result.coverArtUrl val coverBitmap = if (coverUrl == null) { null } else { withTimeoutOrNull(1_500L) { loadBitmap(coverUrl) } } if (coverBitmap != null) { updateNotification( title = result.title, contentText = result.artist, isTerminal = true, contentIntent = pendingIntent, largeIcon = coverBitmap, actionIntent = pendingIntent, actionTitle = getString(R.string.listen_on_metrolist), ) } finishWithPersistentResult() } } private suspend fun loadBitmap(url: String): Bitmap? = withContext(Dispatchers.IO) { runCatching { val connection = (URL(url).openConnection() as? HttpURLConnection) ?: return@runCatching null try { connection.connectTimeout = BITMAP_CONNECT_TIMEOUT_MS connection.readTimeout = BITMAP_READ_TIMEOUT_MS connection.instanceFollowRedirects = true connection.doInput = true connection.connect() connection.inputStream.use(BitmapFactory::decodeStream) } finally { connection.disconnect() } }.getOrNull() } private fun createResultPendingIntent(result: RecognitionResult): PendingIntent { val launchIntent = Intent(this, MainActivity::class.java).apply { action = MainActivity.ACTION_RECOGNITION putExtra(EXTRA_RECOGNITION_TRACK_ID, result.trackId) putExtra(EXTRA_RECOGNITION_TITLE, result.title) putExtra(EXTRA_RECOGNITION_ARTIST, result.artist) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP } return PendingIntent.getActivity( this, RESULT_PENDING_INTENT_REQUEST_CODE, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } private fun finishWithPersistentResult() { keepNotificationOnStop = true stopForeground(STOP_FOREGROUND_DETACH) stopSelf() } private fun createNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val channel = NotificationChannel( CHANNEL_ID, getString(R.string.recognition_notification_channel_name), NotificationManager.IMPORTANCE_LOW, ).apply { description = getString(R.string.recognition_notification_channel_desc) setShowBadge(false) } getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } companion object { const val EXTRA_RECOGNITION_TRACK_ID = "recognition_track_id" const val EXTRA_RECOGNITION_TITLE = "recognition_title" const val EXTRA_RECOGNITION_ARTIST = "recognition_artist" private const val CHANNEL_ID = "recognition_channel" private const val NOTIFICATION_ID = 9100 private const val RESULT_PENDING_INTENT_REQUEST_CODE = 9101 private const val TAG = "RecognitionFgService" private const val BITMAP_CONNECT_TIMEOUT_MS = 1_200 private const val BITMAP_READ_TIMEOUT_MS = 1_200 } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/recognition/RecognitionLaunchActivity.kt ================================================ package com.metrolist.music.recognition import android.Manifest import android.app.Activity import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import androidx.core.content.ContextCompat import com.metrolist.music.MainActivity class RecognitionLaunchActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleRecognitionLaunch() } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) handleRecognitionLaunch() } private fun handleRecognitionLaunch() { if (hasRecordPermission()) { startRecognitionService() } else { openRecognitionPermissionFlow() } finish() } private fun hasRecordPermission(): Boolean { return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED } private fun openRecognitionPermissionFlow() { val intent = Intent(this, MainActivity::class.java).apply { action = MainActivity.ACTION_RECOGNITION putExtra(MainActivity.EXTRA_AUTO_START_RECOGNITION, true) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP } startActivity(intent) } private fun startRecognitionService() { if (!hasRecordPermission()) return val serviceIntent = Intent(this, RecognitionForegroundService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(serviceIntent) } else { startService(serviceIntent) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/recognition/ShazamSignatureGenerator.kt ================================================ package com.metrolist.music.recognition import android.util.Base64 import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.zip.CRC32 import kotlin.math.PI import kotlin.math.cos import kotlin.math.ln import kotlin.math.max /** * Pure Kotlin implementation of the Shazam audio fingerprinting algorithm. * * Ported from the vibra C++ library (https://github.com/marin-m/SongRec) which implements * the Shazam signature algorithm using FFT-based audio fingerprinting. * * This replaces the native C++ + FFTW3 implementation with a pure JVM solution. */ internal object ShazamSignatureGenerator { private const val SAMPLE_RATE = 16_000 private const val FFT_SIZE = 2048 private const val FFT_OUTPUT_SIZE = FFT_SIZE / 2 + 1 // 1025 private const val MAX_PEAKS = 255 private const val MAX_TIME_SECONDS = 12.0 // Spread ring buffer size private const val RING_BUF_SIZE = 256 // Band IDs matching FrequencyBand enum in C++ (0=250-520Hz, 1=520-1450Hz, 2=1450-3500Hz, 3=3500-5500Hz) private const val BAND_250_520 = 0 private const val BAND_520_1450 = 1 private const val BAND_1450_3500 = 2 private const val BAND_3500_5500 = 3 /** * Hanning window: w[i] = 0.5 * (1 - cos(2π*(i+1)/2049)) for i=0..2047. * * This matches the precomputed HANNIG_MATRIX values in the C++ hanning.h header. */ private val HANNING = DoubleArray(FFT_SIZE) { i -> 0.5 * (1.0 - cos(2.0 * PI * (i + 1).toDouble() / 2049.0)) } /** * Generates a Shazam-compatible audio fingerprint from raw 16-bit PCM samples. * * @param samples ByteArray of mono PCM audio (16-bit signed little-endian, 16kHz) * @return Signature URI string (data:audio/vnd.shazam.sig;base64,...) */ fun fromI16(samples: ByteArray): String { require(samples.size >= 2 && samples.size % 2 == 0) { "samples must be a non-empty byte array with even length (16-bit PCM)" } val pcm = ShortArray(samples.size / 2) ByteBuffer.wrap(samples).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(pcm) return SignatureGeneratorState().process(pcm) } private class SignatureGeneratorState { // Circular buffer for 2048 raw samples (as Shorts stored in Int for speed) private val samplesRing = IntArray(FFT_SIZE) private var samplesPos = 0 // Circular buffer of FFT magnitude outputs (RING_BUF_SIZE x FFT_OUTPUT_SIZE) private val fftOutputs = Array(RING_BUF_SIZE) { DoubleArray(FFT_OUTPUT_SIZE) } private var fftPos = 0 private var fftNumWritten = 0 // Circular buffer of time-spread FFT outputs (RING_BUF_SIZE x FFT_OUTPUT_SIZE) private val spreadFfts = Array(RING_BUF_SIZE) { DoubleArray(FFT_OUTPUT_SIZE) } private var spreadPos = 0 private var spreadNumWritten = 0 // Accumulated samples count (for signature header) private var numSamples = 0 // Band → list of peaks (bands 0..3) private val bandPeaks = Array(4) { mutableListOf() } private var totalPeaks = 0 fun process(pcm: ShortArray): String { var offset = 0 while (offset + 128 <= pcm.size) { // Match C++ stopping condition: stop when BOTH time≥max AND peaks≥max val elapsedSec = numSamples.toDouble() / SAMPLE_RATE if (elapsedSec >= MAX_TIME_SECONDS && totalPeaks >= MAX_PEAKS) break numSamples += 128 feedSamples(pcm, offset, 128) doFFT() doPeakSpreadingAndRecognition() offset += 128 } return encodeSignature() } private fun feedSamples(pcm: ShortArray, start: Int, count: Int) { for (k in start until start + count) { samplesRing[samplesPos] = pcm[k].toInt() samplesPos = (samplesPos + 1) % FFT_SIZE } } private fun doFFT() { // Build windowed excerpt from ring buffer (oldest → newest) val windowed = DoubleArray(FFT_SIZE) { i -> samplesRing[(samplesPos + i) % FFT_SIZE].toDouble() * HANNING[i] } val result = computeRfft(windowed) result.copyInto(fftOutputs[fftPos]) fftPos = (fftPos + 1) % RING_BUF_SIZE fftNumWritten++ } private fun doPeakSpreadingAndRecognition() { doPeakSpreading() if (spreadNumWritten >= 47) { doPeakRecognition() } } private fun doPeakSpreading() { // Start with a copy of the last FFT output val lastFftIdx = (fftPos - 1 + RING_BUF_SIZE) % RING_BUF_SIZE val spread = fftOutputs[lastFftIdx].copyOf() // Frequency spreading: 3-point running max (in-place, forward pass) for (pos in 0 until FFT_OUTPUT_SIZE - 2) { spread[pos] = maxOf(spread[pos], spread[pos + 1], spread[pos + 2]) } // Time spreading: propagate max to/from older spread entries at offsets -1, -3, -6 // Only older entries are updated; the new entry keeps only frequency spreading (matches C++). for (pos in 0 until FFT_OUTPUT_SIZE) { var maxVal = spread[pos] for (offset in intArrayOf(-1, -3, -6)) { val idx = ((spreadPos + offset) % RING_BUF_SIZE + RING_BUF_SIZE) % RING_BUF_SIZE val oldVal = spreadFfts[idx][pos] if (oldVal > maxVal) maxVal = oldVal spreadFfts[idx][pos] = maxVal } // Note: spread[pos] is intentionally NOT updated here. // The new entry stored in spreadFfts should only have frequency spreading applied, // not time spreading. This matches the original C++ vibra implementation. } spread.copyInto(spreadFfts[spreadPos]) spreadPos = (spreadPos + 1) % RING_BUF_SIZE spreadNumWritten++ } private fun doPeakRecognition() { val fftMinus46 = fftOutputs[(fftPos - 46 + RING_BUF_SIZE * 2) % RING_BUF_SIZE] val spreadMinus49 = spreadFfts[(spreadPos - 49 + RING_BUF_SIZE * 2) % RING_BUF_SIZE] val otherOffsets = intArrayOf(-53, -45, 165, 172, 179, 186, 193, 200, 214, 221, 228, 235, 242, 249) for (binPos in 10 until FFT_OUTPUT_SIZE - 8) { val fftVal = fftMinus46[binPos] if (fftVal < 1.0 / 64.0 || fftVal < spreadMinus49[binPos]) continue // Check 8 neighbors in spreadMinus49 var maxNeighborSpread49 = 0.0 for (neighborOffset in intArrayOf(-10, -7, -4, -3, 1, 2, 5, 8)) { val v = spreadMinus49[binPos + neighborOffset] if (v > maxNeighborSpread49) maxNeighborSpread49 = v } if (fftVal <= maxNeighborSpread49) continue // Check 14 other spread FFT offsets var maxNeighborOther = maxNeighborSpread49 for (otherOffset in otherOffsets) { val spreadIdx = ((spreadPos + otherOffset) % RING_BUF_SIZE + RING_BUF_SIZE) % RING_BUF_SIZE val v = spreadFfts[spreadIdx][binPos - 1] if (v > maxNeighborOther) maxNeighborOther = v } if (fftVal <= maxNeighborOther) continue // Valid peak found: compute corrected bin and frequency val fftNumber = spreadNumWritten - 46 val peakMag = ln(max(1.0 / 64.0, fftVal)) * 1477.3 + 6144 val peakMagBefore = ln(max(1.0 / 64.0, fftMinus46[binPos - 1])) * 1477.3 + 6144 val peakMagAfter = ln(max(1.0 / 64.0, fftMinus46[binPos + 1])) * 1477.3 + 6144 val peakVariation1 = peakMag * 2 - peakMagBefore - peakMagAfter val peakVariation2 = (peakMagAfter - peakMagBefore) * 32 / peakVariation1 val correctedBin = binPos * 64.0 + peakVariation2 val frequencyHz = correctedBin * (16000.0 / 2.0 / 1024.0 / 64.0) val band = when { frequencyHz < 250.0 -> continue frequencyHz < 520.0 -> BAND_250_520 frequencyHz < 1450.0 -> BAND_520_1450 frequencyHz < 3500.0 -> BAND_1450_3500 frequencyHz <= 5500.0 -> BAND_3500_5500 else -> continue } bandPeaks[band].add( FrequencyPeak( fftPassNumber = fftNumber, peakMagnitude = peakMag.toInt(), correctedPeakFrequencyBin = correctedBin.toInt() ) ) totalPeaks++ } } private fun encodeSignature(): String { val contentsStream = ByteArrayOutputStream() // Write each frequency band's peaks in ascending band order (matches C++ std::map iteration) for (bandId in 0..3) { val peaks = bandPeaks[bandId] if (peaks.isEmpty()) continue val peakBuf = ByteArrayOutputStream() var prevFftPassNumber = 0 for (peak in peaks) { val diff = peak.fftPassNumber - prevFftPassNumber if (diff >= 255) { // Encode absolute position with 0xFF marker peakBuf.write(0xFF) writeLittleEndian32(peakBuf, peak.fftPassNumber) prevFftPassNumber = peak.fftPassNumber } peakBuf.write(peak.fftPassNumber - prevFftPassNumber) writeLittleEndian16(peakBuf, peak.peakMagnitude) writeLittleEndian16(peakBuf, peak.correctedPeakFrequencyBin) prevFftPassNumber = peak.fftPassNumber } val peakBytes = peakBuf.toByteArray() // Band tag: 0x60030040 + bandId writeLittleEndian32(contentsStream, 0x60030040 + bandId) writeLittleEndian32(contentsStream, peakBytes.size) contentsStream.write(peakBytes) // Pad to 4-byte alignment val padBytes = (4 - peakBytes.size % 4) % 4 repeat(padBytes) { contentsStream.write(0) } } val contents = contentsStream.toByteArray() val sizeMinusHeader = contents.size + 8 val samplesAndOffset = (numSamples + SAMPLE_RATE * 0.24).toInt() // Build 48-byte header struct (all fields little-endian) val headerBytes = ByteBuffer.allocate(48).order(ByteOrder.LITTLE_ENDIAN).apply { putInt(0xcafe2580.toInt()) // magic1 putInt(0) // crc32 placeholder putInt(sizeMinusHeader) // size_minus_header putInt(0x94119c00.toInt()) // magic2 putInt(0); putInt(0); putInt(0) // void1[3] putInt(3 shl 27) // shifted_sample_rate_id putInt(0); putInt(0) // void2[2] putInt(samplesAndOffset) // number_samples_plus_divided_sample_rate putInt((15 shl 19) + 0x40000) // fixed_value }.array() // Assemble full buffer: header(48) + 0x40000000(4) + sizeMinusHeader(4) + contents val fullBuf = ByteArrayOutputStream(56 + contents.size) fullBuf.write(headerBytes) writeLittleEndian32(fullBuf, 0x40000000) writeLittleEndian32(fullBuf, contents.size + 8) fullBuf.write(contents) val fullBytes = fullBuf.toByteArray() // CRC32 over bytes from offset 8 to end (skipping magic1 and the crc32 field itself) val crc = CRC32() crc.update(fullBytes, 8, fullBytes.size - 8) val crc32Value = crc.value.toInt() // Write CRC32 at offset 4 (little-endian) fullBytes[4] = (crc32Value and 0xFF).toByte() fullBytes[5] = ((crc32Value shr 8) and 0xFF).toByte() fullBytes[6] = ((crc32Value shr 16) and 0xFF).toByte() fullBytes[7] = ((crc32Value shr 24) and 0xFF).toByte() val base64 = Base64.encodeToString(fullBytes, Base64.NO_WRAP) return "data:audio/vnd.shazam.sig;base64,$base64" } } private data class FrequencyPeak( val fftPassNumber: Int, val peakMagnitude: Int, val correctedPeakFrequencyBin: Int ) private fun writeLittleEndian32(out: ByteArrayOutputStream, value: Int) { out.write(value and 0xFF) out.write((value ushr 8) and 0xFF) out.write((value ushr 16) and 0xFF) out.write((value ushr 24) and 0xFF) } private fun writeLittleEndian16(out: ByteArrayOutputStream, value: Int) { out.write(value and 0xFF) out.write((value ushr 8) and 0xFF) } /** * Computes the real-input FFT of [windowed] (size 2048) using an iterative * Cooley-Tukey radix-2 DIT algorithm. * * Returns FFT_OUTPUT_SIZE (1025) magnitude values: * magnitude[k] = max((re[k]² + im[k]²) / 2^17, 1e-10) * * This matches the FFTW3 r2c output format used in the C++ vibra library. */ private fun computeRfft(windowed: DoubleArray): DoubleArray { val n = windowed.size // 2048 val re = windowed.copyOf() val im = DoubleArray(n) // Bit-reversal permutation var j = 0 for (i in 1 until n) { var bit = n ushr 1 while (j and bit != 0) { j = j xor bit bit = bit ushr 1 } j = j xor bit if (i < j) { var tmp = re[i]; re[i] = re[j]; re[j] = tmp tmp = im[i]; im[i] = im[j]; im[j] = tmp } } // Cooley-Tukey butterfly stages (11 stages for n=2048) var len = 2 while (len <= n) { val halfLen = len ushr 1 val ang = -PI / halfLen // = -2π / len val wBaseRe = cos(ang) val wBaseIm = kotlin.math.sin(ang) var i = 0 while (i < n) { var wRe = 1.0 var wIm = 0.0 for (k in 0 until halfLen) { val u = i + k val v = u + halfLen val evenRe = re[u] val evenIm = im[u] val oddRe = re[v] * wRe - im[v] * wIm val oddIm = re[v] * wIm + im[v] * wRe re[u] = evenRe + oddRe im[u] = evenIm + oddIm re[v] = evenRe - oddRe im[v] = evenIm - oddIm val newWRe = wRe * wBaseRe - wIm * wBaseIm wIm = wRe * wBaseIm + wIm * wBaseRe wRe = newWRe } i += len } len = len shl 1 } // Extract magnitudes for bins 0..n/2 (FFT_OUTPUT_SIZE = 1025) val scaleFactor = 1.0 / (1 shl 17) val minVal = 1e-10 return DoubleArray(FFT_OUTPUT_SIZE) { idx -> val r = re[idx] val img = im[idx] val mag = (r * r + img * img) * scaleFactor if (mag < minVal) minVal else mag } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/recognition/VibraSignature.kt ================================================ package com.metrolist.music.recognition /** * Audio fingerprint generator for Shazam-compatible signatures. * * Pure Kotlin implementation — no native C++ or FFTW3 dependency required. * Uses [ShazamSignatureGenerator] which ports the vibra algorithm to JVM. */ object VibraSignature { const val REQUIRED_SAMPLE_RATE = 16_000 /** * Generates a Shazam signature from raw PCM audio data. * * @param samples Raw PCM audio data (mono, 16-bit signed little-endian, 16kHz) * @return The encoded signature URI string suitable for the Shazam API * @throws IllegalArgumentException if samples is empty or has odd length */ @JvmStatic fun fromI16(samples: ByteArray): String = ShazamSignatureGenerator.fromI16(samples) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/AppNavigation.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import com.metrolist.music.ui.screens.Screens import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @Immutable private data class NavItemState( val isSelected: Boolean, val iconRes: Int ) @Stable private fun isRouteSelected(currentRoute: String?, screenRoute: String, navigationItems: List): Boolean { if (currentRoute == null) return false if (currentRoute == screenRoute) return true return navigationItems.any { it.route == screenRoute } && currentRoute.startsWith("$screenRoute/") } @Composable fun AppNavigationRail( navigationItems: List, currentRoute: String?, onItemClick: (Screens, Boolean) -> Unit, modifier: Modifier = Modifier, pureBlack: Boolean = false, onSearchLongClick: (() -> Unit)? = null ) { val containerColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer val haptics = LocalHapticFeedback.current val viewConfiguration = LocalViewConfiguration.current NavigationRail( modifier = modifier, containerColor = containerColor ) { Spacer(modifier = Modifier.weight(1f)) navigationItems.forEach { screen -> val isSelected = remember(currentRoute, screen.route) { isRouteSelected(currentRoute, screen.route, navigationItems) } val iconRes = remember(isSelected, screen) { if (isSelected) screen.iconIdActive else screen.iconIdInactive } val isSearchItem = screen == Screens.Search && onSearchLongClick != null val interactionSource = remember { MutableInteractionSource() } // Long press detection using InteractionSource if (isSearchItem) { LaunchedEffect(interactionSource) { var isLongClick = false interactionSource.interactions.collectLatest { interaction -> when (interaction) { is PressInteraction.Press -> { isLongClick = false delay(viewConfiguration.longPressTimeoutMillis) isLongClick = true haptics.performHapticFeedback(HapticFeedbackType.LongPress) onSearchLongClick.invoke() } is PressInteraction.Release -> { if (!isLongClick) { onItemClick(screen, isSelected) } } is PressInteraction.Cancel -> { isLongClick = false } } } } } NavigationRailItem( selected = isSelected, onClick = { if (!isSearchItem) { onItemClick(screen, isSelected) } // For search item, click is handled via InteractionSource }, interactionSource = interactionSource, icon = { Icon( painter = painterResource(id = iconRes), contentDescription = stringResource(screen.titleId) ) } ) } Spacer(modifier = Modifier.weight(1f)) } } @Composable fun AppNavigationBar( navigationItems: List, currentRoute: String?, onItemClick: (Screens, Boolean) -> Unit, modifier: Modifier = Modifier, pureBlack: Boolean = false, slimNav: Boolean = false, onSearchLongClick: (() -> Unit)? = null ) { val containerColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer val contentColor = if (pureBlack) Color.White else MaterialTheme.colorScheme.onSurfaceVariant val haptics = LocalHapticFeedback.current val viewConfiguration = LocalViewConfiguration.current NavigationBar( modifier = modifier, containerColor = containerColor, contentColor = contentColor ) { navigationItems.forEach { screen -> val isSelected = remember(currentRoute, screen.route) { isRouteSelected(currentRoute, screen.route, navigationItems) } val iconRes = remember(isSelected, screen) { if (isSelected) screen.iconIdActive else screen.iconIdInactive } val isSearchItem = screen == Screens.Search && onSearchLongClick != null val interactionSource = remember { MutableInteractionSource() } // Long press detection using InteractionSource if (isSearchItem) { LaunchedEffect(interactionSource) { var isLongClick = false interactionSource.interactions.collectLatest { interaction -> when (interaction) { is PressInteraction.Press -> { isLongClick = false delay(viewConfiguration.longPressTimeoutMillis) isLongClick = true haptics.performHapticFeedback(HapticFeedbackType.LongPress) onSearchLongClick.invoke() } is PressInteraction.Release -> { if (!isLongClick) { onItemClick(screen, isSelected) } } is PressInteraction.Cancel -> { isLongClick = false } } } } } NavigationBarItem( selected = isSelected, onClick = { if (!isSearchItem) { onItemClick(screen, isSelected) } // For search item, click is handled via InteractionSource }, interactionSource = interactionSource, icon = { Icon( painter = painterResource(id = iconRes), contentDescription = stringResource(screen.titleId) ) }, label = if (!slimNav) { { Text( text = stringResource(screen.titleId), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } else null ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/AutoResizeText.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/BigSeekBar.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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()), ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheet.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.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.Velocity import androidx.compose.ui.unit.dp import com.metrolist.music.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, isExpandable: Boolean = true, content: @Composable BoxScope.() -> Unit, ) { val density = LocalDensity.current 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() // Use graphicsLayer for offset to ensure hardware acceleration and 120Hz support .graphicsLayer { val y = (state.expandedBound - state.value) .toPx() .coerceAtLeast(0f) translationY = y } .pointerInput(state, isExpandable) { if (!isExpandable) return@pointerInput 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) } ) } .graphicsLayer { val cornerRadius = if (!state.isExpanded) 16.dp.toPx() else 0f shape = RoundedCornerShape(topStart = cornerRadius, topEnd = cornerRadius) clip = true } ) { if (!state.isCollapsed && !state.isDismissed) { BackHandler(onBack = state::collapseSoft) } // main content if (!state.isCollapsed) { BoxWithConstraints( modifier = Modifier .fillMaxSize() .graphicsLayer { alpha = ((state.progress - 0.15f) * 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 = { if (isExpandable) 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!!) } } suspend fun dismissAndWait() { onAnchorChanged(dismissedAnchor) 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 ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheetMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.WindowInsets 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.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetDefaults import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.material3.SheetState import androidx.compose.material3.contentColorFor import androidx.compose.material3.rememberModalBottomSheetState 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.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.graphics.Shape import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.Dp 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) fun show(content: @Composable ColumnScope.() -> Unit) { isVisible = true this.content = content } fun dismiss() { isVisible = false } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun AnimatedBottomSheet( isVisible: Boolean, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, shape: Shape = BottomSheetDefaults.ExpandedShape, containerColor: Color = BottomSheetDefaults.ContainerColor, contentColor: Color = contentColorFor(containerColor), tonalElevation: Dp = 0.dp, scrimColor: Color = BottomSheetDefaults.ScrimColor, dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets }, properties: ModalBottomSheetProperties = ModalBottomSheetDefaults.properties, content: @Composable ColumnScope.() -> Unit, ) { var lastContent by remember { mutableStateOf(content) } LaunchedEffect(content) { if (isVisible) { lastContent = content } } LaunchedEffect(isVisible) { if (isVisible) { sheetState.show() } else { sheetState.hide() } } if (!sheetState.isVisible && !isVisible) { return } ModalBottomSheet( onDismissRequest = onDismissRequest, modifier = modifier, sheetState = sheetState, sheetMaxWidth = sheetMaxWidth, shape = shape, containerColor = containerColor, contentColor = contentColor, tonalElevation = tonalElevation, scrimColor = scrimColor, dragHandle = dragHandle, contentWindowInsets = contentWindowInsets, properties = properties, content = lastContent, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun BottomSheetMenu( modifier: Modifier = Modifier, state: MenuState, background: Color = MaterialTheme.colorScheme.surface, ) { val focusManager = LocalFocusManager.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) AnimatedBottomSheet( isVisible = state.isVisible, onDismissRequest = { focusManager.clearFocus() state.isVisible = false }, sheetState = sheetState, 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) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/BottomSheetPage.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.NavigationBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable 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.platform.LocalFocusManager import androidx.compose.ui.unit.dp 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 } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun BottomSheetPage( modifier: Modifier = Modifier, state: BottomSheetPageState, background: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation), ) { val focusManager = LocalFocusManager.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) AnimatedBottomSheet( isVisible = state.isVisible, onDismissRequest = { focusManager.clearFocus() state.isVisible = false }, sheetState = sheetState, containerColor = background, contentColor = MaterialTheme.colorScheme.onSurface, dragHandle = { Box( modifier = Modifier .padding(vertical = 12.dp) .size(width = 32.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 = 16.dp) .padding(bottom = 16.dp) ) { state.content(this) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/ChipsRow.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.width import androidx.compose.foundation.layout.windowInsetsPadding 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.metrolist.music.R import com.metrolist.music.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()) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)), ) { 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) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)), ) { 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()) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)), ) { 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 ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/CreatePlaylistDialog.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import android.widget.Toast 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.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 com.metrolist.innertube.YouTube import com.metrolist.music.LocalDatabase import com.metrolist.music.R import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.extensions.isSyncEnabled import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.time.LocalDateTime import java.util.logging.Logger @Composable fun CreatePlaylistDialog( onDismiss: () -> Unit, initialTextFieldValue: String? = null, allowSyncing: Boolean = true, onPlaylistCreated: ((String) -> Unit)? = null, ) { val database = LocalDatabase.current val coroutineScope = rememberCoroutineScope() var syncedPlaylist by remember { mutableStateOf(false) } val context = LocalContext.current val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") val isSignedIn = innerTubeCookie.isNotEmpty() val notLoggedInYoutubeStr = stringResource(R.string.not_logged_in_youtube) val syncDisabledStr = stringResource(R.string.sync_disabled) 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 && isSignedIn) { YouTube.createPlaylist(playlistName) } else if (syncedPlaylist) { Logger.getLogger("CreatePlaylistDialog").warning("Not signed in") return@launch } else { null } val playlistEntity = PlaylistEntity( name = playlistName, browseId = browseId, bookmarkedAt = LocalDateTime.now(), isEditable = true, ) database.query { insert(playlistEntity) } withContext(Dispatchers.Main) { onPlaylistCreated?.invoke(playlistEntity.id) } } }, 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 = { val isYtmSyncEnabled = context.isSyncEnabled() if (!isSignedIn && !syncedPlaylist) { Toast .makeText( context, notLoggedInYoutubeStr, Toast.LENGTH_SHORT, ).show() } else if (!isYtmSyncEnabled) { Toast .makeText( context, syncDisabledStr, Toast.LENGTH_SHORT, ).show() } else { syncedPlaylist = !syncedPlaylist } }, ) } } } }, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/Dialog.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape 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.draw.clip 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.KeyboardType 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 androidx.navigation.NavController import com.metrolist.music.R import com.metrolist.music.ui.screens.settings.AccountSettings 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, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, 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 = horizontalAlignment, 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)) FlowRow( modifier = Modifier.align(Alignment.End) ) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) { ProvideTextStyle( value = MaterialTheme.typography.labelLarge ) { buttons() } } } } } } } } @Composable fun AccountSettingsDialog( navController: NavController, onDismiss: () -> Unit, latestVersionName: String ) { Dialog( onDismissRequest = onDismiss, properties = DialogProperties( usePlatformDefaultWidth = false, dismissOnClickOutside = true ) ) { Box( modifier = Modifier .fillMaxSize() .clickable( indication = null, interactionSource = remember { MutableInteractionSource() } ) { onDismiss() }, contentAlignment = Alignment.TopCenter ) { Surface( modifier = Modifier .fillMaxWidth() .padding(top = 72.dp, start = 16.dp, end = 16.dp) .clip(RoundedCornerShape(28.dp)), shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface, tonalElevation = 8.dp ) { AccountSettings( navController = navController, onClose = onDismiss, latestVersionName = latestVersionName ) } } } } @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 = {} ) { DefaultDialog( onDismiss = onDismiss, title = if (titleBar != null) { { Row { titleBar() } } } else if (title != null) { { Text( text = title, overflow = TextOverflow.Ellipsis, maxLines = 1, style = MaterialTheme.typography.headlineSmall, ) } } else null, buttons = { 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)) } } ) { content() } } @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) .imePadding(), ) { 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() }, keyboardType: KeyboardType = KeyboardType.Text, onDone: (String) -> Unit = {}, // new multi-field support textFields: List>? = null, onTextFieldsChange: ((Int, TextFieldValue) -> Unit)? = null, onDoneMultiple: ((List) -> Unit)? = null, onDismiss: () -> Unit, autoDismiss: Boolean = true, extraContent: (@Composable () -> Unit)? = null, ) { val legacyFieldState = 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)) } val isValid = textFields?.all { isInputValid(it.second.text) } ?: isInputValid(legacyFieldState.value.text) TextButton( enabled = isValid, onClick = { if (autoDismiss) onDismiss() if (textFields != null && onDoneMultiple != null) { onDoneMultiple(textFields.map { it.second.text }) } else { onDone(legacyFieldState.value.text) } } ) { Text(text = stringResource(android.R.string.ok)) } } ) { Column( modifier = Modifier.weight(weight = 1f, fill = false) ) { if (textFields != null) { textFields.forEachIndexed { index, (label, value) -> TextField( value = value, onValueChange = { onTextFieldsChange?.invoke(index, it) }, placeholder = { Text(label) }, singleLine = singleLine, maxLines = maxLines, colors = OutlinedTextFieldDefaults.colors(), keyboardOptions = KeyboardOptions( imeAction = if (singleLine) ImeAction.Done else ImeAction.None, keyboardType = keyboardType ), keyboardActions = KeyboardActions( onDone = { if (onDoneMultiple != null) { onDoneMultiple(textFields.map { it.second.text }) if (autoDismiss) onDismiss() } } ), modifier = Modifier .fillMaxWidth() .padding(bottom = if (index < textFields.size - 1) 12.dp else 0.dp) .then(if (index == 0) Modifier.focusRequester(focusRequester) else Modifier) ) } } else { TextField( value = legacyFieldState.value, onValueChange = { legacyFieldState.value = it }, placeholder = placeholder, singleLine = singleLine, maxLines = maxLines, colors = OutlinedTextFieldDefaults.colors(), keyboardOptions = KeyboardOptions( imeAction = if (singleLine) ImeAction.Done else ImeAction.None, keyboardType = keyboardType ), keyboardActions = KeyboardActions( onDone = { onDone(legacyFieldState.value.text) if (autoDismiss) onDismiss() } ), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) ) } extraContent?.invoke() } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/DraggableLyricsProviderList.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.foundation.layout.Arrangement 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.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape 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.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.graphics.painter.Painter import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.res.painterResource import com.metrolist.music.R import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState data class DraggableLyricsProviderItem( val id: String, val name: String, val icon: Painter, ) @Composable fun DraggableLyricsProviderList( items: MutableList, onItemsReordered: (List) -> Unit, modifier: Modifier = Modifier, ) { val lazyListState = rememberLazyListState() var hasDragged by remember { mutableStateOf(false) } val reorderableState = rememberReorderableLazyListState( lazyListState = lazyListState, ) { from, to -> val movedItem = items.removeAt(from.index) items.add(to.index, movedItem) hasDragged = true } LaunchedEffect(reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging && hasDragged) { onItemsReordered(items.toList()) hasDragged = false } } LazyColumn( state = lazyListState, modifier = modifier, ) { itemsIndexed( items, key = { _, item -> item.id } ) { _, item -> ReorderableItem( state = reorderableState, key = item.id, ) { Surface( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceContainer, ) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton( onClick = { }, modifier = Modifier.draggableHandle(), ) { Icon( painter = painterResource(R.drawable.drag_handle), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } Text( text = item.name, style = MaterialTheme.typography.bodyLarge, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/DraggableScrollBarOverlay.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.spring import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf 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.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.max @Composable fun DraggableScrollbar( scrollState: LazyListState, modifier: Modifier = Modifier, thumbColor: Color = LocalContentColor.current.copy(alpha = 0.8f), thumbColorActive: Color = MaterialTheme.colorScheme.secondary, thumbHeight: Dp = 72.dp, thumbWidth: Dp = 8.dp, thumbCornerRadius: Dp = 4.dp, trackWidth: Dp = 24.dp, minItemCountForScroll: Int = 15, minScrollRangeForDrag: Int = 5, headerItems: Int = 0 ) { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() var isDragging by remember { mutableStateOf(false) } var lastScrollTime by remember { mutableLongStateOf(0L) } var smoothedY by remember { mutableFloatStateOf(0f) } var smoothedThumbY by remember { mutableFloatStateOf(0f) } var lastThumbPosition by remember { mutableFloatStateOf(0f) } val animatedThumbY = remember { Animatable(0f) } val isUserScrolling by remember(scrollState) { derivedStateOf { scrollState.isScrollInProgress } } val isScrollable by remember { derivedStateOf { val layoutInfo = scrollState.layoutInfo val total = layoutInfo.totalItemsCount val visible = layoutInfo.visibleItemsInfo.size val contentCount = total - headerItems contentCount > minItemCountForScroll && contentCount > visible } } if (!isScrollable) return var lastTargetIndex by remember { mutableIntStateOf(-1) } BoxWithConstraints( modifier = modifier .width(trackWidth) .fillMaxHeight() .pointerInput(scrollState) { detectDragGestures( onDragStart = { offset -> isDragging = true lastTargetIndex = -1 val viewportHeight = size.height.toFloat() val constThumbHeight = with(density) { thumbHeight.toPx() } val maxThumbY = viewportHeight - constThumbHeight smoothedThumbY = (offset.y - constThumbHeight / 2).coerceIn(0f, maxThumbY) }, onDragEnd = { isDragging = false lastScrollTime = 0L }, onDragCancel = { isDragging = false lastScrollTime = 0L } ) { change, _ -> val currentTime = System.currentTimeMillis() val viewportHeight = size.height.toFloat() val constThumbHeight = with(density) { thumbHeight.toPx() } val maxThumbY = viewportHeight - constThumbHeight val targetThumbY = (change.position.y - constThumbHeight / 2).coerceIn(0f, maxThumbY) val layoutInfo = scrollState.layoutInfo val totalContentItems = layoutInfo.totalItemsCount - headerItems val thumbSmoothingFactor = when { totalContentItems < 20 -> 0.1f totalContentItems < 50 -> 0.3f else -> 0.7f } smoothedThumbY = smoothedThumbY * (1f - thumbSmoothingFactor) + targetThumbY * thumbSmoothingFactor if (currentTime - lastScrollTime < 40) return@detectDragGestures lastScrollTime = currentTime val visibleItems = layoutInfo.visibleItemsInfo if (visibleItems.isEmpty()) return@detectDragGestures val maxScrollIndex = max(1, totalContentItems - visibleItems.size) if (maxScrollIndex > minScrollRangeForDrag) { val touchProgress = (change.position.y / size.height).coerceIn(0f, 1f) val listSmoothingFactor = when { totalContentItems < 20 -> 0.15f totalContentItems < 50 -> 0.4f else -> 0.8f } smoothedY = smoothedY * (1f - listSmoothingFactor) + touchProgress * listSmoothingFactor val targetFractionalIndex = smoothedY * maxScrollIndex val targetIndex = (headerItems + targetFractionalIndex.toInt()) .coerceIn(headerItems, layoutInfo.totalItemsCount - 1) if (abs(targetIndex - lastTargetIndex) >= 1) { lastTargetIndex = targetIndex coroutineScope.launch { try { scrollState.animateScrollToItem( index = targetIndex, scrollOffset = 0 ) } catch (e: Exception) { } } } } } } ) { val viewportHeight = with(density) { this@BoxWithConstraints.maxHeight.toPx() } val constThumbHeight = with(density) { thumbHeight.toPx() } val targetThumbY by remember { derivedStateOf { val layoutInfo = scrollState.layoutInfo val visibleItems = layoutInfo.visibleItemsInfo if (visibleItems.isEmpty()) return@derivedStateOf lastThumbPosition val totalContentItems = layoutInfo.totalItemsCount - headerItems val maxScrollIndex = max(1, totalContentItems - visibleItems.size) if (maxScrollIndex <= minScrollRangeForDrag) return@derivedStateOf lastThumbPosition val rawIndex = (scrollState.firstVisibleItemIndex - headerItems).coerceAtLeast(0) val scrollProgress = if (totalContentItems < 30) { val currentProgress = rawIndex.toFloat() / maxScrollIndex val smoothingFactor = 0.2f val previousProgress = lastThumbPosition / (viewportHeight - constThumbHeight) previousProgress * (1f - smoothingFactor) + currentProgress * smoothingFactor } else { rawIndex.toFloat() / maxScrollIndex } val maxThumbY = viewportHeight - constThumbHeight val newPosition = (scrollProgress * maxThumbY).coerceIn(0f, maxThumbY) lastThumbPosition = newPosition newPosition } } LaunchedEffect(targetThumbY, isDragging, isUserScrolling, smoothedThumbY) { val layoutInfo = scrollState.layoutInfo val totalContentItems = layoutInfo.totalItemsCount - headerItems when { isDragging -> { animatedThumbY.snapTo(smoothedThumbY) } isUserScrolling -> { if (totalContentItems < 30) { animatedThumbY.animateTo( targetValue = targetThumbY, animationSpec = spring( stiffness = 100f, dampingRatio = 1.2f ) ) } else { animatedThumbY.snapTo(targetThumbY) } } else -> { animatedThumbY.animateTo( targetValue = targetThumbY, animationSpec = spring( stiffness = if (totalContentItems < 30) 80f else 150f, dampingRatio = if (totalContentItems < 30) 1.5f else 0.9f ) ) } } } Canvas( modifier = Modifier .width(thumbWidth) .fillMaxHeight() .align(Alignment.CenterEnd) ) { val color = if (isDragging) thumbColorActive else thumbColor val cornerRadiusPx = thumbCornerRadius.toPx() drawRoundRect( color = color, topLeft = Offset(0f, animatedThumbY.value), size = Size(this.size.width, constThumbHeight), cornerRadius = CornerRadius(cornerRadiusPx) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/EmptyPlaceholder.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/EnumDialog.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.foundation.clickable 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.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun EnumDialog( onDismiss: () -> Unit, onSelect: (T) -> Unit, title: String, current: T, values: List, valueText: @Composable (T) -> String, valueDescription: (@Composable (T) -> String)? = null, ) { ListDialog( onDismiss = onDismiss, ) { items(values) { value -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { onSelect(value) } .padding(horizontal = 16.dp, vertical = 12.dp), ) { RadioButton( selected = value == current, onClick = null, ) Column( modifier = Modifier.padding(start = 16.dp), ) { Text( text = valueText(value), ) if (valueDescription != null) { Text( text = valueDescription(value), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/ExpandableText.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.animation.animateContentSize import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.MaterialTheme 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler 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.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.metrolist.music.R data class LinkSegment( val text: String, val url: String? = null, ) @Composable fun ExpandableText( text: String = "", runs: List? = null, modifier: Modifier = Modifier, collapsedMaxLines: Int = 3, ) { var isExpanded by rememberSaveable { mutableStateOf(false) } var hasOverflow by rememberSaveable { mutableStateOf(false) } val uriHandler = LocalUriHandler.current val linkColor = MaterialTheme.colorScheme.primary val bodyColor = MaterialTheme.colorScheme.onSurfaceVariant val annotatedText: AnnotatedString = remember(text, runs, linkColor) { if (runs.isNullOrEmpty()) { AnnotatedString(text) } else { buildAnnotatedString { runs.forEach { segment -> if (segment.url != null) { pushStringAnnotation(tag = "URL", annotation = segment.url) withStyle(SpanStyle(color = linkColor)) { append(segment.text) } pop() } else { append(segment.text) } } } } } Column( modifier = modifier.animateContentSize() ) { @Suppress("DEPRECATION") ClickableText( text = annotatedText, style = MaterialTheme.typography.bodyMedium.copy(color = bodyColor), maxLines = if (isExpanded) Int.MAX_VALUE else collapsedMaxLines, overflow = TextOverflow.Ellipsis, onTextLayout = { textLayoutResult -> hasOverflow = textLayoutResult.hasVisualOverflow || textLayoutResult.lineCount > collapsedMaxLines }, onClick = { offset -> annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) .firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) return@ClickableText } if (hasOverflow) { isExpanded = !isExpanded } } ) if (hasOverflow) { Text( text = stringResource(if (isExpanded) R.string.show_less else R.string.show_more), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, modifier = Modifier .padding(top = 4.dp) .clickable { isExpanded = !isExpanded } ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/GridMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.music.R import com.metrolist.music.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.action_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() ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/HideOnScrollFAB.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.padding import androidx.compose.foundation.layout.size 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.material3.MaterialTheme import androidx.compose.material3.SmallFloatingActionButton 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.res.stringResource import androidx.compose.ui.unit.dp import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.ui.utils.isScrollingUp @Composable fun BoxScope.HideOnScrollFAB( visible: Boolean = true, lazyListState: LazyListState, @DrawableRes icon: Int, onClick: () -> Unit, onRecognitionClick: (() -> Unit)? = null, ) { AnimatedVisibility( visible = visible && lazyListState.isScrollingUp(), enter = slideInVertically { it }, exit = slideOutVertically { it }, modifier = Modifier .align(Alignment.BottomEnd) .windowInsetsPadding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), ), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { if (onRecognitionClick != null) { SmallFloatingActionButton( onClick = onRecognitionClick, containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(40.dp) ) { Icon( painter = painterResource(R.drawable.mic), contentDescription = stringResource(R.string.recognize_music), modifier = Modifier.size(20.dp) ) } Spacer(modifier = Modifier.height(12.dp)) } FloatingActionButton( onClick = onClick, ) { Icon( painter = painterResource(icon), contentDescription = null, ) } } } } @Composable fun BoxScope.HideOnScrollFAB( visible: Boolean = true, lazyListState: LazyGridState, @DrawableRes icon: Int, onClick: () -> Unit, onRecognitionClick: (() -> Unit)? = null, ) { AnimatedVisibility( visible = visible && lazyListState.isScrollingUp(), enter = slideInVertically { it }, exit = slideOutVertically { it }, modifier = Modifier .align(Alignment.BottomEnd) .windowInsetsPadding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), ), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { if (onRecognitionClick != null) { SmallFloatingActionButton( onClick = onRecognitionClick, containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(40.dp) ) { Icon( painter = painterResource(R.drawable.mic), contentDescription = stringResource(R.string.recognize_music), modifier = Modifier.size(20.dp) ) } Spacer(modifier = Modifier.height(12.dp)) } FloatingActionButton( onClick = onClick, ) { Icon( painter = painterResource(icon), contentDescription = null, ) } } } } @Composable fun BoxScope.HideOnScrollFAB( visible: Boolean = true, scrollState: ScrollState, @DrawableRes icon: Int, onClick: () -> Unit, onRecognitionClick: (() -> Unit)? = null, ) { AnimatedVisibility( visible = visible && scrollState.isScrollingUp(), enter = slideInVertically { it }, exit = slideOutVertically { it }, modifier = Modifier .align(Alignment.BottomEnd) .windowInsetsPadding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), ), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { if (onRecognitionClick != null) { SmallFloatingActionButton( onClick = onRecognitionClick, containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(40.dp) ) { Icon( painter = painterResource(R.drawable.mic), contentDescription = stringResource(R.string.recognize_music), modifier = Modifier.size(20.dp) ) } Spacer(modifier = Modifier.height(12.dp)) } FloatingActionButton( onClick = onClick, ) { Icon( painter = painterResource(icon), contentDescription = null, ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/IconButton.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/IntegrationCard.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults 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 /** * A Material 3 Expressive style settings group component * @param title The title of the settings group * @param items List of settings items to display */ @Composable fun IntegrationCard( title: String? = null, items: List ) { Column( modifier = Modifier .fillMaxWidth() ) { // Section title title?.let { Text( text = it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp, top = 8.dp) ) } // Settings items Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp) ) { items.forEachIndexed { index, item -> val shape = when { items.size == 1 -> RoundedCornerShape(24.dp) index == 0 -> RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 6.dp, bottomEnd = 6.dp) index == items.size - 1 -> RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 24.dp, bottomEnd = 24.dp) else -> RoundedCornerShape(6.dp) } Card( modifier = Modifier .fillMaxWidth() .animateContentSize(), shape = shape, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { IntegrationCardItemRow(item = item) } } } } } /** * Individual settings item row with Material 3 styling */ @Composable private fun IntegrationCardItemRow( item: IntegrationCardItem ) { Row( modifier = Modifier .fillMaxWidth() .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)) ProvideTextStyle( MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onSurfaceVariant ) ) { desc() } } } // Trailing content item.trailingContent?.let { trailing -> Spacer(modifier = Modifier.width(8.dp)) trailing() } } } /** * Data class for Material 3 settings item */ data class IntegrationCardItem( 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 ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/Items.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors * * Optimized for minimal recomposition during navigation */ package com.metrolist.music.ui.component import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animate 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.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope 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.fillMaxSize 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.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.ExperimentalMaterial3Api 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.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.Shape import androidx.compose.ui.graphics.graphicsLayer 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.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.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.zIndex import androidx.media3.common.MediaItem 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 coil3.compose.AsyncImage import coil3.request.ImageRequest import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.YTItem import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.CropAlbumArtKey import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.constants.SmallGridThumbnailHeight import com.metrolist.music.constants.SwipeToSongKey import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Artist import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.MediaMetadata import com.metrolist.music.playback.queues.LocalAlbumRadio import com.metrolist.music.ui.utils.resize import com.metrolist.music.utils.joinByBullet import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.utils.reportException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.math.roundToInt const val ActiveBoxAlpha = 0.6f @Composable fun currentGridThumbnailHeight(): Dp { val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) return if (gridItemSize == GridItemSize.BIG) GridThumbnailHeight else SmallGridThumbnailHeight } // Basic list item - optimized with inline to reduce recomposition @Composable inline fun ListItem( modifier: Modifier = Modifier, title: String, noinline subtitle: (@Composable RowScope.() -> Unit)? = null, thumbnailContent: @Composable () -> Unit, trailingContent: @Composable RowScope.() -> Unit = {}, isSelected: Boolean? = false, isActive: Boolean = false, isAvailable: Boolean = true, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = if (isActive) { modifier // playing highlight .height(ListItemHeight) .padding(horizontal = 8.dp) .clip(RoundedCornerShape(8.dp)) .background( color = // selected active if (isSelected == true) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) else MaterialTheme.colorScheme.secondaryContainer ) } else if (isSelected == true) { modifier // inactive selected .height(ListItemHeight) .padding(horizontal = 8.dp) .clip(RoundedCornerShape(8.dp)) .background(color = MaterialTheme.colorScheme.inversePrimary.copy(alpha = 0.4f)) } else { modifier // default .height(ListItemHeight) .padding(horizontal = 8.dp) } ) { Box( modifier = Modifier.padding(6.dp), contentAlignment = Alignment.Center ) { thumbnailContent() if (!isAvailable) { Box( modifier = Modifier .size(ListThumbnailSize) .align(Alignment.Center) .background( Color.Black.copy(alpha = 0.25f), RoundedCornerShape(ThumbnailCornerRadius) ) ) { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, tint = Color.White, modifier = Modifier .size(ListThumbnailSize / 2) .align(Alignment.Center) .graphicsLayer { alpha = 1f } ) } } } Column( modifier = Modifier .weight(1f) .padding(horizontal = 6.dp) ) { Text( text = title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis ) if (subtitle != null) { Row(verticalAlignment = Alignment.CenterVertically) { subtitle() } } } trailingContent() } } @Composable fun ListItem( modifier: Modifier = Modifier, title: String, subtitle: AnnotatedString?, badges: @Composable RowScope.() -> Unit = {}, thumbnailContent: @Composable () -> Unit, trailingContent: @Composable RowScope.() -> Unit = {}, isSelected: Boolean? = false, isActive: Boolean = false, ) = ListItem( title = title, subtitle = { badges() if (subtitle != null) { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis ) } }, thumbnailContent = thumbnailContent, trailingContent = trailingContent, modifier = modifier, isSelected = isSelected, isActive = isActive ) // merge badges and subtitle text and pass to basic list item @Composable fun ListItem( modifier: Modifier = Modifier, title: String, subtitle: String?, badges: @Composable RowScope.() -> Unit = {}, thumbnailContent: @Composable () -> Unit, trailingContent: @Composable RowScope.() -> Unit = {}, isSelected: Boolean? = false, isActive: Boolean = false, ) = ListItem( title = title, subtitle = { badges() if (!subtitle.isNullOrEmpty()) { Text( text = subtitle, color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) } }, thumbnailContent = thumbnailContent, trailingContent = trailingContent, modifier = modifier, isSelected = isSelected, isActive = isActive ) @Composable fun GridItem( modifier: Modifier = Modifier, title: @Composable () -> Unit, subtitle: @Composable () -> Unit, badges: @Composable RowScope.() -> Unit = {}, thumbnailContent: @Composable BoxWithConstraintsScope.() -> Unit, thumbnailRatio: Float = 1f, fillMaxWidth: Boolean = false, ) { val gridHeight = currentGridThumbnailHeight() Column( modifier = if (fillMaxWidth) { modifier .padding(12.dp) .fillMaxWidth() } else { modifier .padding(12.dp) .width(gridHeight * thumbnailRatio) } ) { BoxWithConstraints( contentAlignment = Alignment.Center, modifier = if (fillMaxWidth) { Modifier.fillMaxWidth() } else { Modifier.height(gridHeight) } .aspectRatio(thumbnailRatio) ) { thumbnailContent() } Spacer(modifier = Modifier.height(6.dp)) title() Row(verticalAlignment = Alignment.CenterVertically) { badges() subtitle() } } } @Composable fun GridItem( modifier: Modifier = Modifier, title: String, subtitle: String, badges: @Composable RowScope.() -> Unit = {}, thumbnailContent: @Composable BoxWithConstraintsScope.() -> Unit, thumbnailRatio: Float = 1f, fillMaxWidth: Boolean = false, ) = GridItem( modifier = modifier, title = { Text( text = title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Start, modifier = Modifier.fillMaxWidth() ) }, subtitle = { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, thumbnailContent = thumbnailContent, thumbnailRatio = thumbnailRatio, fillMaxWidth = fillMaxWidth ) @Composable fun SongListItem( song: Song, modifier: Modifier = Modifier, albumIndex: Int? = null, showLikedIcon: Boolean = true, showInLibraryIcon: Boolean = false, showDownloadIcon: Boolean = true, subtitleOverride: String? = null, badges: @Composable RowScope.() -> Unit = { if (showLikedIcon && song.song.liked) { Icon.Favorite() } if (song.song.explicit) { Icon.Explicit() } if (showInLibraryIcon && song.song.inLibrary != null) { Icon.Library() } if (showDownloadIcon) { val download by LocalDownloadUtil.current.getDownload(song.id) .collectAsState(initial = null) Icon.Download(download?.state) } }, isSelected: Boolean = false, isActive: Boolean = false, isPlaying: Boolean = false, isSwipeable: Boolean = true, trailingContent: @Composable RowScope.() -> Unit = {}, ) { val swipeEnabled by rememberPreference(SwipeToSongKey, defaultValue = false) val content: @Composable () -> Unit = { ListItem( title = song.song.title, subtitle = subtitleOverride ?: joinByBullet( song.orderedArtists.joinToString { it.name }, makeTimeString(song.song.duration * 1000L) ), badges = badges, thumbnailContent = { ItemThumbnail( thumbnailUrl = song.song.thumbnailUrl, albumIndex = albumIndex, isSelected = isSelected, isActive = isActive, isPlaying = isPlaying, shape = RoundedCornerShape(ThumbnailCornerRadius), modifier = Modifier.size(ListThumbnailSize) ) }, trailingContent = trailingContent, modifier = modifier, isSelected = isSelected, isActive = isActive ) } if (isSwipeable && swipeEnabled) { SwipeToSongBox( mediaItem = song.toMediaItem(), modifier = Modifier.fillMaxWidth() ) { content() } } else { content() } } @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.Favorite() } if (showInLibraryIcon && song.song.inLibrary != null) { Icon.Library() } if (showDownloadIcon) { val download by LocalDownloadUtil.current.getDownload(song.id).collectAsState(initial = null) Icon.Download(download?.state) } }, isActive: Boolean = false, isPlaying: Boolean = false, fillMaxWidth: Boolean = false, ) = GridItem( title = { Text( text = song.song.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee().fillMaxWidth() ) }, subtitle = { Text( text = joinByBullet( song.orderedArtists.joinToString { it.name }, makeTimeString(song.song.duration * 1000L) ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis, ) }, badges = badges, thumbnailContent = { val gridHeight = currentGridThumbnailHeight() ItemThumbnail( thumbnailUrl = song.song.thumbnailUrl, isActive = isActive, isPlaying = isPlaying, shape = RoundedCornerShape(ThumbnailCornerRadius), modifier = Modifier.size(gridHeight) ) if (!isActive) { OverlayPlayButton( visible = true ) } }, fillMaxWidth = fillMaxWidth, 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 = if (artist.songCount > 0) pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount) else null, badges = badges, thumbnailContent = { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(artist.artist.thumbnailUrl) .memoryCachePolicy(coil3.request.CachePolicy.ENABLED) .diskCachePolicy(coil3.request.CachePolicy.ENABLED) .networkCachePolicy(coil3.request.CachePolicy.ENABLED) .build(), 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.Favorite() } }, fillMaxWidth: Boolean = false, ) = GridItem( title = artist.artist.name, subtitle = if (artist.songCount > 0) pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount) else "", badges = badges, thumbnailContent = { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(artist.artist.thumbnailUrl) .memoryCachePolicy(coil3.request.CachePolicy.ENABLED) .diskCachePolicy(coil3.request.CachePolicy.ENABLED) .networkCachePolicy(coil3.request.CachePolicy.ENABLED) .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() .clip(CircleShape) ) }, fillMaxWidth = fillMaxWidth, modifier = modifier ) @Composable fun AlbumListItem( album: Album, modifier: Modifier = Modifier, showLikedIcon: Boolean = true, badges: @Composable RowScope.() -> Unit = { val downloadUtil = LocalDownloadUtil.current val database = LocalDatabase.current val songs by produceState>(initialValue = emptyList(), album.id) { withContext(Dispatchers.IO) { value = database.albumSongs(album.id).first() } } val allDownloads by downloadUtil.downloads.collectAsState() val downloadState by remember(songs, allDownloads) { androidx.compose.runtime.mutableIntStateOf( if (songs.isEmpty()) { Download.STATE_STOPPED } else { when { songs.all { allDownloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED songs.any { allDownloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING) } -> STATE_DOWNLOADING else -> Download.STATE_STOPPED } } ) } if (showLikedIcon && album.album.bookmarkedAt != null) { Icon.Favorite() } if (album.album.explicit) { Icon.Explicit() } Icon.Download(downloadState) }, 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 = { ItemThumbnail( thumbnailUrl = album.album.thumbnailUrl, isActive = isActive, isPlaying = isPlaying, shape = RoundedCornerShape(ThumbnailCornerRadius), modifier = Modifier.size(ListThumbnailSize) ) }, trailingContent = trailingContent, modifier = modifier ) @Composable fun AlbumGridItem( album: Album, modifier: Modifier = Modifier, coroutineScope: CoroutineScope, badges: @Composable RowScope.() -> Unit = { val downloadUtil = LocalDownloadUtil.current val database = LocalDatabase.current val songs by produceState>(initialValue = emptyList(), album.id) { withContext(Dispatchers.IO) { value = database.albumSongs(album.id).first() } } val allDownloads by downloadUtil.downloads.collectAsState() val downloadState by remember(songs, allDownloads) { androidx.compose.runtime.mutableIntStateOf( if (songs.isEmpty()) { Download.STATE_STOPPED } else { when { songs.all { allDownloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED songs.any { allDownloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING) } -> STATE_DOWNLOADING else -> Download.STATE_STOPPED } } ) } if (album.album.bookmarkedAt != null) { Icon.Favorite() } if (album.album.explicit) { Icon.Explicit() } Icon.Download(downloadState) }, isActive: Boolean = false, isPlaying: Boolean = false, fillMaxWidth: Boolean = false, ) = GridItem( title = { Text( text = album.album.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee().fillMaxWidth() ) }, subtitle = { Text( text = album.artists.joinToString { it.name }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis ) }, badges = badges, thumbnailContent = { val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return@GridItem val scope = rememberCoroutineScope() ItemThumbnail( thumbnailUrl = album.album.thumbnailUrl, isActive = isActive, isPlaying = isPlaying, shape = RoundedCornerShape(ThumbnailCornerRadius), ) AlbumPlayButton( visible = !isActive, onClick = { scope.launch { val albumWithSongs = withContext(Dispatchers.IO) { database.albumWithSongs(album.id).firstOrNull() } albumWithSongs?.let { playerConnection.playQueue(LocalAlbumRadio(it)) } } } ) }, fillMaxWidth = fillMaxWidth, modifier = modifier ) @Composable fun PlaylistListItem( playlist: Playlist, modifier: Modifier = Modifier, autoPlaylist: Boolean = false, badges: @Composable RowScope.() -> Unit = { val downloadUtil = LocalDownloadUtil.current val database = LocalDatabase.current val songs by produceState>(initialValue = emptyList(), playlist.id) { withContext(Dispatchers.IO) { value = database.playlistSongs(playlist.id).first().map { it.song } } } val allDownloads by downloadUtil.downloads.collectAsState() val downloadState by remember(songs, allDownloads) { androidx.compose.runtime.mutableIntStateOf( if (songs.isEmpty()) { Download.STATE_STOPPED } else { when { songs.all { allDownloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED songs.any { allDownloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING) } -> STATE_DOWNLOADING else -> Download.STATE_STOPPED } } ) } Icon.Download(downloadState) }, trailingContent: @Composable RowScope.() -> Unit = {} ) = 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 ) } }, badges = badges, thumbnailContent = { PlaylistThumbnail( thumbnails = playlist.thumbnails, size = ListThumbnailSize, placeHolder = { 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 // R.drawable.backup as placeholder stringResource(R.string.uploaded_playlist) -> R.drawable.backup else -> if (autoPlaylist) R.drawable.trending_up else R.drawable.queue_music } Icon( painter = painterResource(painter), contentDescription = null, tint = LocalContentColor.current.copy(alpha = 0.8f), modifier = Modifier.size(ListThumbnailSize / 2) ) }, shape = RoundedCornerShape(ThumbnailCornerRadius) ) }, trailingContent = trailingContent, modifier = modifier ) @Composable fun PlaylistGridItem( playlist: Playlist, modifier: Modifier = Modifier, autoPlaylist: Boolean = false, badges: @Composable RowScope.() -> Unit = { val downloadUtil = LocalDownloadUtil.current val database = LocalDatabase.current val songs by produceState>(initialValue = emptyList(), playlist.id) { withContext(Dispatchers.IO) { value = database.playlistSongs(playlist.id).first().map { it.song } } } val allDownloads by downloadUtil.downloads.collectAsState() val downloadState by remember(songs, allDownloads) { mutableIntStateOf( if (songs.isEmpty()) { Download.STATE_STOPPED } else { when { songs.all { allDownloads[it.id]?.state == STATE_COMPLETED } -> STATE_COMPLETED songs.any { allDownloads[it.id]?.state in listOf(STATE_QUEUED, STATE_DOWNLOADING) } -> STATE_DOWNLOADING else -> Download.STATE_STOPPED } } ) } Icon.Download(downloadState) }, fillMaxWidth: Boolean = false, ) = GridItem( title = { Text( text = playlist.playlist.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee().fillMaxWidth() ) }, subtitle = { val 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 ) } } Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis ) }, badges = badges, thumbnailContent = { val width = maxWidth PlaylistThumbnail( thumbnails = playlist.thumbnails, size = width, placeHolder = { 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 // R.drawable.backup as placeholder stringResource(R.string.uploaded_playlist) -> R.drawable.backup else -> if (autoPlaylist) R.drawable.trending_up else R.drawable.queue_music } Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Icon( painter = painterResource(painter), contentDescription = null, tint = LocalContentColor.current.copy(alpha = 0.8f), modifier = Modifier.size(width / 2) ) } }, shape = RoundedCornerShape(ThumbnailCornerRadius) ) }, fillMaxWidth = fillMaxWidth, modifier = modifier ) @Composable fun MediaMetadataListItem( mediaMetadata: MediaMetadata, modifier: Modifier = Modifier, isSelected: Boolean = false, isActive: Boolean = false, isPlaying: Boolean = false, trailingContent: @Composable RowScope.() -> Unit = {}, ) { ListItem( title = mediaMetadata.title, subtitle = if (mediaMetadata.suggestedBy != null) { buildAnnotatedString { append(mediaMetadata.artists.joinToString { it.name }) append(" • ") append(makeTimeString(mediaMetadata.duration * 1000L)) append(" • ") withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(mediaMetadata.suggestedBy) } } } else { AnnotatedString( joinByBullet( mediaMetadata.artists.joinToString { it.name }, makeTimeString(mediaMetadata.duration * 1000L) ) ) }, badges = { if (mediaMetadata.explicit) Icon.Explicit()}, thumbnailContent = { ItemThumbnail( thumbnailUrl = mediaMetadata.thumbnailUrl, albumIndex = null, isSelected = isSelected, isActive = isActive, isPlaying = isPlaying, shape = RoundedCornerShape(ThumbnailCornerRadius), modifier = Modifier.size(ListThumbnailSize) ) }, trailingContent = trailingContent, modifier = modifier, isActive = isActive ) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun YouTubeListItem( item: YTItem, modifier: Modifier = Modifier, albumIndex: Int? = null, isSelected: Boolean = false, isActive: Boolean = false, isPlaying: Boolean = false, isSwipeable: Boolean = true, trailingContent: @Composable RowScope.() -> Unit = {}, badges: @Composable RowScope.() -> Unit = { val database = LocalDatabase.current val song by produceState(initialValue = null, item.id) { if (item is SongItem) value = database.song(item.id).firstOrNull() } val album by produceState(initialValue = null, item.id) { if (item is AlbumItem) value = database.album(item.id).firstOrNull() } if ((item is SongItem && song?.song?.liked == true) || (item is AlbumItem && album?.album?.bookmarkedAt != null) ) { Icon.Favorite() } if (item.explicit) Icon.Explicit() // if (item is SongItem && song?.song?.inLibrary != null) { // Icon.Library() // } if (item is SongItem) { val download by LocalDownloadUtil.current.getDownload(item.id).collectAsState(null) Icon.Download(download?.state) } }, ) { val swipeEnabled by rememberPreference(SwipeToSongKey, defaultValue = false) val content: @Composable () -> 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) is PodcastItem -> joinByBullet(item.author?.name, item.episodeCountText) is EpisodeItem -> joinByBullet(item.author?.name, item.publishDateText, makeTimeString(item.duration?.times(1000L))) }, badges = badges, thumbnailContent = { ItemThumbnail( thumbnailUrl = item.thumbnail, albumIndex = albumIndex, isSelected = isSelected, isActive = isActive, isPlaying = isPlaying, shape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius), modifier = Modifier.size(ListThumbnailSize) ) }, trailingContent = trailingContent, modifier = modifier, isActive = isActive ) } if (item is SongItem && isSwipeable && swipeEnabled) { SwipeToSongBox( mediaItem = item.copy(thumbnail = item.thumbnail.resize(544,544)).toMediaItem(), modifier = Modifier.fillMaxWidth() ) { content() } } else { content() } } @Composable fun YouTubeGridItem( item: YTItem, modifier: Modifier = Modifier, coroutineScope: CoroutineScope? = null, badges: @Composable RowScope.() -> Unit = { val database = LocalDatabase.current val song by produceState(initialValue = null, item.id) { if (item is SongItem) value = database.song(item.id).firstOrNull() } val album by produceState(initialValue = null, item.id) { if (item is AlbumItem) value = database.album(item.id).firstOrNull() } if (item is SongItem && song?.song?.liked == true || item is AlbumItem && album?.album?.bookmarkedAt != null ) { Icon.Favorite() } if (item.explicit) Icon.Explicit() // if (item is SongItem && song?.song?.inLibrary != null) Icon.Library() if (item is SongItem) { val download by LocalDownloadUtil.current.getDownload(item.id).collectAsState(null) Icon.Download(download?.state) } }, thumbnailRatio: Float = if (item is SongItem) 16f / 9 else 1f, isActive: Boolean = false, isPlaying: Boolean = false, fillMaxWidth: Boolean = false, ) = GridItem( title = { 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().fillMaxWidth() ) }, subtitle = { 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) is PodcastItem -> joinByBullet(item.author?.name, item.episodeCountText) is EpisodeItem -> joinByBullet(item.author?.name, makeTimeString(item.duration?.times(1000L))) } if (subtitle != null) { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } }, badges = badges, thumbnailContent = { val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return@GridItem val scope = rememberCoroutineScope() ItemThumbnail( thumbnailUrl = item.thumbnail, isActive = isActive, isPlaying = isPlaying, shape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius), ) if (item is SongItem && !isActive) { OverlayPlayButton( visible = true ) } AlbumPlayButton( visible = item is AlbumItem && !isActive, onClick = { scope.launch(Dispatchers.IO) { var albumWithSongs = database.albumWithSongs(item.id).first() if (albumWithSongs?.songs.isNullOrEmpty()) { YouTube.album(item.id).onSuccess { albumPage -> database.transaction { insert(albumPage) } albumWithSongs = database.albumWithSongs(item.id).first() }.onFailure { reportException(it) } } albumWithSongs?.let { withContext(Dispatchers.Main) { playerConnection.playQueue(LocalAlbumRadio(it)) } } } } ) }, thumbnailRatio = thumbnailRatio, fillMaxWidth = fillMaxWidth, modifier = modifier ) @Composable fun LocalSongsGrid( title: String, subtitle: String, badges: @Composable RowScope.() -> Unit = {}, thumbnailUrl: String?, isActive: Boolean = false, isPlaying: Boolean = false, fillMaxWidth: Boolean = false, modifier: Modifier = Modifier ) = GridItem( title = { Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) }, subtitle = { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee( iterations = 3, initialDelayMillis = 1000, velocity = 30.dp ) ) }, badges = badges, thumbnailContent = { LocalThumbnail( thumbnailUrl = thumbnailUrl, isActive = isActive, isPlaying = isPlaying, shape = RoundedCornerShape(ThumbnailCornerRadius), modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, showCenterPlay = true, playButtonVisible = false ) }, 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 = Modifier ) = GridItem( title = { Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) }, subtitle = { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee( iterations = 3, initialDelayMillis = 1000, velocity = 30.dp ) ) }, badges = badges, thumbnailContent = { LocalThumbnail( thumbnailUrl = thumbnailUrl, isActive = false, isPlaying = false, shape = CircleShape, modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, showCenterPlay = false, playButtonVisible = false ) }, 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 = Modifier ) = GridItem( title = { Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) }, subtitle = { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee( iterations = 3, initialDelayMillis = 1000, velocity = 30.dp ) ) }, badges = badges, thumbnailContent = { LocalThumbnail( thumbnailUrl = thumbnailUrl, isActive = isActive, isPlaying = isPlaying, shape = RoundedCornerShape(ThumbnailCornerRadius), modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier, showCenterPlay = false, playButtonVisible = true ) }, fillMaxWidth = fillMaxWidth, modifier = modifier ) @Composable fun ItemThumbnail( thumbnailUrl: String?, isActive: Boolean, isPlaying: Boolean, shape: Shape, modifier: Modifier = Modifier, albumIndex: Int? = null, isSelected: Boolean = false, thumbnailRatio: Float = 1f ) { val cropAlbumArt by rememberPreference(CropAlbumArtKey, false) Box( contentAlignment = Alignment.Center, modifier = modifier .fillMaxSize() .aspectRatio(thumbnailRatio) .clip(shape) ) { if (albumIndex == null) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(thumbnailUrl) .memoryCachePolicy(coil3.request.CachePolicy.ENABLED) .diskCachePolicy(coil3.request.CachePolicy.ENABLED) .networkCachePolicy(coil3.request.CachePolicy.ENABLED) .build(), contentDescription = null, contentScale = if (cropAlbumArt) ContentScale.Crop else ContentScale.Fit, modifier = Modifier .fillMaxWidth() .clip(shape) ) } if (albumIndex != null) { AnimatedVisibility( visible = !isActive, enter = fadeIn() + expandIn(expandFrom = Alignment.Center), exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() ) { Text( text = albumIndex.toString(), style = MaterialTheme.typography.labelLarge ) } } if (isSelected) { Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxSize() .zIndex(1f) .clip(shape) .background(Color.Black.copy(alpha = 0.5f)) ) { Icon( painter = painterResource(R.drawable.done), contentDescription = null ) } } 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 = ActiveBoxAlpha), shape = shape ) ) } } @Composable fun LocalThumbnail( thumbnailUrl: String?, isActive: Boolean, isPlaying: Boolean, shape: Shape, modifier: Modifier = Modifier, showCenterPlay: Boolean = false, playButtonVisible: Boolean = false, thumbnailRatio: Float = 1f ) { val cropAlbumArt by rememberPreference(CropAlbumArtKey, false) Box( contentAlignment = Alignment.Center, modifier = modifier .aspectRatio(thumbnailRatio) .clip(shape) ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(thumbnailUrl) .memoryCachePolicy(coil3.request.CachePolicy.ENABLED) .diskCachePolicy(coil3.request.CachePolicy.ENABLED) .networkCachePolicy(coil3.request.CachePolicy.ENABLED) .build(), contentDescription = null, contentScale = if (cropAlbumArt) ContentScale.Crop else ContentScale.Fit, modifier = Modifier.fillMaxSize() ) AnimatedVisibility( visible = isActive, enter = fadeIn(tween(500)), exit = fadeOut(tween(500)) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.4f), shape) ) { if (isPlaying) { PlayingIndicator( color = Color.White, modifier = Modifier.height(24.dp) ) } else { Icon( painter = painterResource(R.drawable.play), contentDescription = null, tint = Color.White ) } } } if (showCenterPlay) { 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 ) } } } if (playButtonVisible) { AnimatedVisibility( visible = true, enter = fadeIn(), exit = fadeOut(), modifier = Modifier .align(Alignment.BottomEnd) .padding(8.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .size(36.dp) .clip(CircleShape) .background(Color.Black.copy(alpha = ActiveBoxAlpha)) ) { Icon( painter = painterResource(R.drawable.play), contentDescription = null, tint = Color.White ) } } } } } @Composable fun PlaylistThumbnail( thumbnails: List, size: Dp, placeHolder: @Composable () -> Unit, shape: Shape, cacheKey: String? = null ) { val cropAlbumArt by rememberPreference(CropAlbumArtKey, false) when (thumbnails.size) { 0 -> Box( contentAlignment = Alignment.Center, modifier = Modifier .size(size) .clip(shape) .background(MaterialTheme.colorScheme.surfaceContainer) ) { placeHolder() } 1 -> AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(thumbnails[0]) .apply { /* Removed cache key extensions due to unresolved in env */ } .memoryCachePolicy(coil3.request.CachePolicy.ENABLED) .diskCachePolicy(coil3.request.CachePolicy.ENABLED) .networkCachePolicy(coil3.request.CachePolicy.ENABLED) .build(), contentDescription = null, contentScale = if (cropAlbumArt) ContentScale.Crop else ContentScale.Fit, placeholder = painterResource(R.drawable.queue_music), error = painterResource(R.drawable.queue_music), modifier = Modifier .size(size) .clip(shape) ) else -> Box( modifier = Modifier .size(size) .clip(shape) ) { listOf( Alignment.TopStart, Alignment.TopEnd, Alignment.BottomStart, Alignment.BottomEnd ).fastForEachIndexed { index, alignment -> AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(thumbnails.getOrNull(index)) .apply { /* Removed cache key extensions due to unresolved in env */ } .memoryCachePolicy(coil3.request.CachePolicy.ENABLED) .diskCachePolicy(coil3.request.CachePolicy.ENABLED) .networkCachePolicy(coil3.request.CachePolicy.ENABLED) .build(), contentDescription = null, contentScale = if (cropAlbumArt) ContentScale.Crop else ContentScale.Fit, placeholder = painterResource(R.drawable.queue_music), error = painterResource(R.drawable.queue_music), modifier = Modifier .align(alignment) .size(size / 2) ) } } } } @Composable fun BoxScope.OverlayPlayButton( visible: Boolean ) { AnimatedVisibility( visible = visible, enter = fadeIn(), exit = fadeOut(), modifier = Modifier .align(Alignment.Center) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .size(36.dp) .clip(CircleShape) .background(Color.Black.copy(alpha = ActiveBoxAlpha)) ) { Icon( painter = painterResource(R.drawable.play), contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp) ) } } } @Composable fun BoxScope.OverlayEditButton( visible: Boolean, onClick: () -> Unit, alignment: Alignment = Alignment.Center, ) { AnimatedVisibility( visible = visible, enter = fadeIn(), exit = fadeOut(), modifier = Modifier .align(alignment) .then(if (alignment == Alignment.BottomEnd) Modifier.padding(8.dp) else Modifier) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .size(36.dp) .clip(CircleShape) .background(Color.Black.copy(alpha = ActiveBoxAlpha)) .padding(0.dp) .clickable(onClick = onClick) ) { Icon( painter = painterResource(R.drawable.edit), contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp) ) } } } @Composable fun BoxScope.AlbumPlayButton( visible: Boolean, onClick: () -> Unit, ) { AnimatedVisibility( visible = visible, enter = fadeIn(), exit = fadeOut(), modifier = Modifier .align(Alignment.BottomEnd) .padding(8.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .size(36.dp) .clip(CircleShape) .background(Color.Black.copy(alpha = ActiveBoxAlpha)) .clickable(onClick = onClick) ) { Icon( painter = painterResource(R.drawable.play), contentDescription = null, tint = Color.White ) } } } @Composable fun SwipeToSongBox( modifier: Modifier = Modifier, mediaItem: MediaItem, content: @Composable BoxScope.() -> Unit ) { val ctx = LocalContext.current val player = LocalPlayerConnection.current val scope = rememberCoroutineScope() val offset = remember { mutableFloatStateOf(0f) } val threshold = 300f val dragState = rememberDraggableState { delta -> offset.floatValue = (offset.floatValue + delta).coerceIn(-threshold, threshold) } Box( modifier = modifier .fillMaxWidth() .draggable( orientation = Orientation.Horizontal, state = dragState, onDragStopped = { when { offset.floatValue >= threshold -> { player?.playNext(listOf(mediaItem)) Toast.makeText(ctx, R.string.play_next, Toast.LENGTH_SHORT).show() reset(offset, scope) } offset.floatValue <= -threshold -> { player?.addToQueue(listOf(mediaItem)) Toast.makeText(ctx, R.string.add_to_queue, Toast.LENGTH_SHORT).show() reset(offset, scope) } else -> reset(offset, scope) } } ) ) { if (offset.floatValue != 0f) { val (iconRes, bg, tint, align) = if (offset.floatValue > 0) Quadruple( R.drawable.playlist_play, MaterialTheme.colorScheme.secondary, MaterialTheme.colorScheme.onSecondary, Alignment.CenterStart ) else Quadruple( R.drawable.queue_music, MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary, Alignment.CenterEnd ) Box( modifier = Modifier .fillMaxWidth() .height(60.dp) .align(Alignment.Center) .background(bg), contentAlignment = align ) { Icon( painter = painterResource(id = iconRes), contentDescription = null, modifier = Modifier .padding(horizontal = 24.dp) .size(30.dp) .alpha(0.9f), tint = tint ) } } Box( modifier = Modifier .offset { IntOffset(offset.floatValue.roundToInt(), 0) } .fillMaxWidth() .background(MaterialTheme.colorScheme.surface), content = content ) } } // Helper to animate reset of swipe offset private fun reset(offset: MutableState, scope: CoroutineScope) { scope.launch { animate( initialValue = offset.value, targetValue = 0f, animationSpec = tween(durationMillis = 300) ) { value, _ -> offset.value = value } } } // Data holder for swipe visuals data class Quadruple( val first: A, val second: B, val third: C, val fourth: D ) object Icon { @Composable fun Favorite() { Icon( painter = painterResource(R.drawable.favorite), contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier .size(18.dp) .padding(end = 2.dp) ) } @Composable fun Library() { Icon( painter = painterResource(R.drawable.library_add_check), contentDescription = null, modifier = Modifier .size(18.dp) .padding(end = 2.dp) ) } @Composable fun Download(state: Int?) { when (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 -> { /* no icon */ } } } @Composable fun Explicit() { Icon( painter = painterResource(R.drawable.explicit), contentDescription = null, modifier = Modifier .size(18.dp) .padding(end = 2.dp) ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/Library.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component 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.res.painterResource import androidx.navigation.NavController import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.R import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Artist import com.metrolist.music.db.entities.Playlist import com.metrolist.music.ui.menu.AlbumMenu import com.metrolist.music.ui.menu.ArtistMenu import com.metrolist.music.ui.menu.PlaylistMenu import com.metrolist.music.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.browseId != null) 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 ) = PlaylistGridItem( playlist = playlist, fillMaxWidth = true, modifier = modifier .fillMaxWidth() .combinedClickable( onClick = { if (!playlist.playlist.isEditable && playlist.songCount == 0 && playlist.playlist.browseId != null) 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 ) } } } } ) ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/Lyrics.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.text.Layout import android.view.WindowManager import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing 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.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.animateScrollBy 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.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.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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button 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.MaterialTheme 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.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.clip 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.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb 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.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString 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.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.palette.graphics.Palette import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.toBitmap import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.AiProviderKey import com.metrolist.music.constants.AiSystemPromptKey import com.metrolist.music.constants.DarkModeKey import com.metrolist.music.constants.DeeplApiKey import com.metrolist.music.constants.DeeplFormalityKey import com.metrolist.music.constants.LyricsAnimationStyle import com.metrolist.music.constants.LyricsAnimationStyleKey import com.metrolist.music.constants.LyricsClickKey import com.metrolist.music.constants.LyricsGlowEffectKey import com.metrolist.music.constants.LyricsLineSpacingKey import com.metrolist.music.constants.LyricsRomanizeAsMainKey import com.metrolist.music.constants.LyricsRomanizeCyrillicByLineKey import com.metrolist.music.constants.LyricsRomanizeList import com.metrolist.music.constants.LyricsScrollKey import com.metrolist.music.constants.LyricsTextPositionKey import com.metrolist.music.constants.LyricsTextSizeKey import com.metrolist.music.constants.OpenRouterApiKey import com.metrolist.music.constants.OpenRouterBaseUrlKey import com.metrolist.music.constants.OpenRouterModelKey import com.metrolist.music.constants.PlayerBackgroundStyle import com.metrolist.music.constants.PlayerBackgroundStyleKey import com.metrolist.music.constants.TranslateLanguageKey import com.metrolist.music.constants.TranslateModeKey import com.metrolist.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND import com.metrolist.music.lyrics.LyricsEntry import com.metrolist.music.lyrics.LyricsTranslationHelper import com.metrolist.music.lyrics.LyricsUtils.findCurrentLineIndex import com.metrolist.music.lyrics.LyricsUtils.isBelarusian import com.metrolist.music.lyrics.LyricsUtils.isBulgarian import com.metrolist.music.lyrics.LyricsUtils.isChinese import com.metrolist.music.lyrics.LyricsUtils.isHindi import com.metrolist.music.lyrics.LyricsUtils.isJapanese import com.metrolist.music.lyrics.LyricsUtils.isKorean import com.metrolist.music.lyrics.LyricsUtils.isKyrgyz import com.metrolist.music.lyrics.LyricsUtils.isMacedonian import com.metrolist.music.lyrics.LyricsUtils.isRussian import com.metrolist.music.lyrics.LyricsUtils.isSerbian import com.metrolist.music.lyrics.LyricsUtils.isUkrainian import com.metrolist.music.lyrics.LyricsUtils.parseLyrics import com.metrolist.music.lyrics.LyricsUtils.romanizeChinese import com.metrolist.music.lyrics.LyricsUtils.romanizeCyrillic import com.metrolist.music.lyrics.LyricsUtils.romanizeHindi import com.metrolist.music.lyrics.LyricsUtils.romanizeJapanese import com.metrolist.music.lyrics.LyricsUtils.romanizeKorean import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.component.shimmer.TextPlaceholder import com.metrolist.music.ui.screens.settings.DarkMode import com.metrolist.music.ui.screens.settings.LyricsPosition import com.metrolist.music.ui.screens.settings.defaultList import com.metrolist.music.ui.utils.fadingEdge import com.metrolist.music.utils.ComposeToImage import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration.Companion.seconds /** * A composable function that displays lyrics for the currently playing song. * * @param sliderPositionProvider Provides the current playback position in milliseconds. * @param modifier Modifier to be applied to the layout. * @param showLyrics Whether lyrics should be displayed. */ @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @SuppressLint("UnusedBoxWithConstraintsScope", "StringFormatInvalid") @Composable fun Lyrics( sliderPositionProvider: () -> Long?, modifier: Modifier = Modifier, showLyrics: Boolean, ) { val playerConnection = LocalPlayerConnection.current ?: return val database = LocalDatabase.current val menuState = LocalMenuState.current val density = LocalDensity.current val context = LocalContext.current val configuration = LocalWindowInfo.current val listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val shareLyricsStr = stringResource(R.string.share_lyrics) val failedToCreateImageTemplate = stringResource(R.string.failed_to_create_image) val lyricsTextPosition by rememberEnumPreference(LyricsTextPositionKey, LyricsPosition.CENTER) val changeLyrics by rememberPreference(LyricsClickKey, true) val scrollLyrics by rememberPreference(LyricsScrollKey, true) val romanizeLyricsList = rememberPreference(LyricsRomanizeList, "") val romanizeAsMain by rememberPreference(LyricsRomanizeAsMainKey, false) val romanizeCyrillicByLine by rememberPreference(LyricsRomanizeCyrillicByLineKey, false) val lyricsGlowEffect by rememberPreference(LyricsGlowEffectKey, false) val lyricsAnimationStyle by rememberEnumPreference(LyricsAnimationStyleKey, LyricsAnimationStyle.APPLE) val lyricsTextSize by rememberPreference(LyricsTextSizeKey, 24f) val lyricsLineSpacing by rememberPreference(LyricsLineSpacingKey, 1.3f) val openRouterApiKey by rememberPreference(OpenRouterApiKey, "") val deeplApiKey by rememberPreference(DeeplApiKey, "") val aiProvider by rememberPreference(AiProviderKey, "OpenRouter") val openRouterBaseUrl by rememberPreference(OpenRouterBaseUrlKey, "https://openrouter.ai/api/v1/chat/completions") val openRouterModel by rememberPreference(OpenRouterModelKey, "google/gemini-2.5-flash-lite") val translateLanguage by rememberPreference(TranslateLanguageKey, "en") val translateMode by rememberPreference(TranslateModeKey, "Literal") val deeplFormality by rememberPreference(DeeplFormalityKey, "default") val aiSystemPrompt by rememberPreference(AiSystemPromptKey, "") val scope = rememberCoroutineScope() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val lyricsEntity by playerConnection.currentLyrics.collectAsState(initial = null) val currentSong by playerConnection.currentSong.collectAsState(initial = null) val lyrics = remember(lyricsEntity) { lyricsEntity?.lyrics?.trim() } 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 } val decodedList = if (romanizeLyricsList.value.isEmpty()) { defaultList } else { romanizeLyricsList.value.split(",").map { entry -> val (lang, checked) = entry.split(":") Pair(lang, checked.toBoolean()) } } val enabledLanguages = decodedList.filter { (_, checked) -> checked }.map { (lang, _) -> lang } val lines = remember(lyrics, scope) { if (lyrics == null || lyrics == LYRICS_NOT_FOUND) { emptyList() } else if (lyrics.startsWith("[")) { val parsedLines = parseLyrics(lyrics) parsedLines .map { entry -> val newEntry = LyricsEntry(entry.time, entry.text, entry.words, agent = entry.agent, isBackground = entry.isBackground) scope.launch { val text = if (romanizeCyrillicByLine) entry.text else lyrics var value: String? = "" when { "Japanese" in enabledLanguages && isJapanese(text) && !isChinese(text) -> { value = romanizeJapanese(entry.text) } "Korean" in enabledLanguages && isKorean(text) -> { value = romanizeKorean(entry.text) } "Chinese" in enabledLanguages && isChinese(text) -> { value = romanizeChinese(entry.text) } "Hindi" in enabledLanguages && isHindi(text) -> { value = romanizeHindi(entry.text) } "Ukrainian" in enabledLanguages && isUkrainian(text) -> { value = romanizeCyrillic(entry.text) } "Russian" in enabledLanguages && isRussian(text) -> { value = romanizeCyrillic(entry.text) } "Serbian" in enabledLanguages && isSerbian(text) -> { value = romanizeCyrillic(entry.text) } "Bulgarian" in enabledLanguages && isBulgarian(text) -> { value = romanizeCyrillic(entry.text) } "Belarusian" in enabledLanguages && isBelarusian(text) -> { value = romanizeCyrillic(entry.text) } "Kyrgyz" in enabledLanguages && isKyrgyz(text) -> { value = romanizeCyrillic(entry.text) } "Macedonian" in enabledLanguages && isMacedonian(text) -> { value = romanizeCyrillic(entry.text) } } newEntry.romanizedTextFlow.value = value } newEntry }.let { listOf(LyricsEntry.HEAD_LYRICS_ENTRY) + it } } else { lyrics.lines().mapIndexed { index, line -> val newEntry = LyricsEntry(index * 100L, line) scope.launch { val text = if (romanizeCyrillicByLine) line else lyrics var value = newEntry.romanizedTextFlow.value when { "Japanese" in enabledLanguages && isJapanese(text) && !isChinese(text) -> value = romanizeJapanese(line) "Korean" in enabledLanguages && isKorean(text) -> value = romanizeKorean(line) "Chinese" in enabledLanguages && isChinese(text) -> value = romanizeChinese(line) "Hindi" in enabledLanguages && isHindi(text) -> value = romanizeHindi(line) "Ukrainian" in enabledLanguages && isUkrainian(text) -> value = romanizeCyrillic(line) "Russian" in enabledLanguages && isRussian(text) -> value = romanizeCyrillic(line) "Serbian" in enabledLanguages && isSerbian(text) -> value = romanizeCyrillic(line) "Bulgarian" in enabledLanguages && isBulgarian(text) -> value = romanizeCyrillic(line) "Belarusian" in enabledLanguages && isBelarusian(text) -> value = romanizeCyrillic(line) "Kyrgyz" in enabledLanguages && isKyrgyz(text) -> value = romanizeCyrillic(line) "Macedonian" in enabledLanguages && isMacedonian(text) -> value = romanizeCyrillic(line) } newEntry.romanizedTextFlow.value = value } newEntry } } } val isSynced = remember(lyrics) { !lyrics.isNullOrEmpty() && lyrics.startsWith("[") } // State for translation status val translationStatus by LyricsTranslationHelper.status.collectAsState() // Track composition lifecycle DisposableEffect(Unit) { LyricsTranslationHelper.setCompositionActive(true) onDispose { LyricsTranslationHelper.setCompositionActive(false) LyricsTranslationHelper.cancelTranslation() } } // Load translations from database on initial display LaunchedEffect(lines, lyricsEntity, translateLanguage, translateMode) { if (lines.isNotEmpty() && lyricsEntity != null) { LyricsTranslationHelper.loadTranslationsFromDatabase( lyrics = lines, lyricsEntity = lyricsEntity, targetLanguage = translateLanguage, mode = translateMode, ) } } val aiApiKeyRequiredStr = stringResource(R.string.ai_api_key_required) // Listen for manual trigger LaunchedEffect(showLyrics, lines.size) { LyricsTranslationHelper.manualTrigger.collect { val effectiveApiKey = if (aiProvider == "DeepL") deeplApiKey else openRouterApiKey if (showLyrics && lines.isNotEmpty() && effectiveApiKey.isNotBlank()) { LyricsTranslationHelper.translateLyrics( lyrics = lines, targetLanguage = translateLanguage, apiKey = openRouterApiKey, baseUrl = openRouterBaseUrl, model = openRouterModel, mode = translateMode, scope = scope, context = context, provider = aiProvider, deeplApiKey = deeplApiKey, deeplFormality = deeplFormality, useStreaming = true, songId = currentSong?.id ?: "", database = database, systemPrompt = aiSystemPrompt, ) } else if (effectiveApiKey.isBlank()) { Toast.makeText(context, aiApiKeyRequiredStr, Toast.LENGTH_SHORT).show() } } } // Listen for clear translations trigger LaunchedEffect(Unit) { LyricsTranslationHelper.clearTranslationsTrigger.collect { lines.forEach { it.translatedTextFlow.value = null } } } // Use Material 3 expressive accents and keep glow/text colors unified val expressiveAccent = when (playerBackground) { PlayerBackgroundStyle.DEFAULT -> { MaterialTheme.colorScheme.primary } PlayerBackgroundStyle.BLUR, PlayerBackgroundStyle.GRADIENT -> { // For blur/gradient backgrounds, always use light colors regardless of theme Color.White } } var currentLineIndex by remember { mutableIntStateOf(-1) } var currentPlaybackPosition by remember { mutableLongStateOf(0L) } // Because LaunchedEffect has delay, which leads to inconsistent with current line color and scroll animation, // we use deferredCurrentLineIndex when user is scrolling var deferredCurrentLineIndex by rememberSaveable { mutableIntStateOf(0) } var previousLineIndex by rememberSaveable { mutableIntStateOf(0) } var lastPreviewTime by rememberSaveable { mutableLongStateOf(0L) } var isSeeking by remember { mutableStateOf(false) } var initialScrollDone by rememberSaveable { mutableStateOf(false) } var shouldScrollToFirstLine by rememberSaveable { mutableStateOf(true) } var isAppMinimized by rememberSaveable { mutableStateOf(false) } var showProgressDialog by remember { mutableStateOf(false) } var showShareDialog by remember { mutableStateOf(false) } var shareDialogData by remember { mutableStateOf?>(null) } var showColorPickerDialog by remember { mutableStateOf(false) } var previewBackgroundColor by remember { mutableStateOf(Color(0xFF242424)) } var previewTextColor by remember { mutableStateOf(Color.White) } var previewSecondaryTextColor by remember { mutableStateOf(Color.White.copy(alpha = 0.7f)) } // State for multi-selection var isSelectionModeActive by rememberSaveable { mutableStateOf(false) } val selectedIndices = remember { mutableStateListOf() } var showMaxSelectionToast by remember { mutableStateOf(false) } // State for showing max selection toast val isLyricsProviderShown = lyricsEntity?.provider != null && lyricsEntity?.provider != "Unknown" && !isSelectionModeActive val lazyListState = rememberLazyListState() // Professional animation states for smooth Metrolist-style transitions var isAnimating by remember { mutableStateOf(false) } var isAutoScrollEnabled by rememberSaveable { mutableStateOf(true) } // Handle back button press - close selection mode instead of exiting screen BackHandler(enabled = isSelectionModeActive) { isSelectionModeActive = false selectedIndices.clear() } // Define max selection limit val maxSelectionLimit = 5 val maxSelectionLimitMsg = stringResource(R.string.max_selection_limit, maxSelectionLimit) // Show toast when max selection is reached LaunchedEffect(showMaxSelectionToast) { if (showMaxSelectionToast) { Toast .makeText( context, maxSelectionLimitMsg, Toast.LENGTH_SHORT, ).show() showMaxSelectionToast = false } } val lifecycleOwner = LocalLifecycleOwner.current // Keep screen on while lyrics are visible DisposableEffect(showLyrics) { val activity = context as? Activity if (showLyrics) { activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } onDispose { activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } 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) } } // Reset selection mode if lyrics change LaunchedEffect(lines) { isSelectionModeActive = false selectedIndices.clear() } LaunchedEffect(lyrics) { if (lyrics.isNullOrEmpty() || !lyrics.startsWith("[")) { currentLineIndex = -1 return@LaunchedEffect } while (isActive) { delay(8) // Faster update for word-by-word animation val sliderPosition = sliderPositionProvider() isSeeking = sliderPosition != null val position = sliderPosition ?: playerConnection.player.currentPosition currentPlaybackPosition = position val lyricsOffset = currentSong?.song?.lyricsOffset ?: 0 currentLineIndex = findCurrentLineIndex(lines, position + lyricsOffset) } } LaunchedEffect(isSeeking, lastPreviewTime) { if (isSeeking) { lastPreviewTime = 0L } else if (lastPreviewTime != 0L) { delay(LyricsPreviewTime) lastPreviewTime = 0L } } /** * Smoothly scrolls the lyrics list to center the item at [targetIndex]. * * @param targetIndex The index of the lyrics line to scroll to. * @param duration The duration of the scroll animation in milliseconds. */ suspend fun performSmoothPageScroll( targetIndex: Int, duration: Int = 1500, ) { if (isAnimating) return // Prevent multiple animations isAnimating = true try { val lookUpIndex = if (isLyricsProviderShown) targetIndex + 1 else targetIndex val itemInfo = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == lookUpIndex } if (itemInfo != null) { // Item is visible, animate directly to center without sudden jumps 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 { // Item is not visible, scroll to it first without animation, then it will be handled in next cycle lazyListState.scrollToItem(targetIndex) } } finally { isAnimating = false } } LaunchedEffect(currentLineIndex, lastPreviewTime, initialScrollDone, isAutoScrollEnabled) { if (!isSynced) return@LaunchedEffect if (isAutoScrollEnabled) { if ((currentLineIndex == 0 && shouldScrollToFirstLine) || !initialScrollDone) { shouldScrollToFirstLine = false // Initial scroll to center the first line with medium animation (600ms) val initialCenterIndex = kotlin.math.max(0, currentLineIndex) performSmoothPageScroll(initialCenterIndex, 800) // Initial scroll duration if (!isAppMinimized) { initialScrollDone = true } } else if (currentLineIndex != -1) { deferredCurrentLineIndex = currentLineIndex if (isSeeking) { // Fast scroll for seeking to center the target line (300ms) val seekCenterIndex = kotlin.math.max(0, currentLineIndex) performSmoothPageScroll(seekCenterIndex, 500) // Fast seek duration } else if ((lastPreviewTime == 0L || currentLineIndex != previousLineIndex) && scrollLyrics) { // Auto-scroll when lyrics settings allow it if (currentLineIndex != previousLineIndex) { // Calculate which line should be at the top to center the active group val centerTargetIndex = currentLineIndex performSmoothPageScroll(centerTargetIndex, 1500) // Auto scroll duration } } } } if (currentLineIndex > 0) { shouldScrollToFirstLine = true } previousLineIndex = currentLineIndex } BoxWithConstraints( contentAlignment = Alignment.TopCenter, modifier = modifier .fillMaxSize() .padding(bottom = 12.dp), ) { // Status UI for translation Box( modifier = Modifier .fillMaxWidth() .zIndex(1f) .padding(top = 56.dp), contentAlignment = Alignment.Center, ) { when (val status = translationStatus) { is LyricsTranslationHelper.TranslationStatus.Translating -> { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer, ), shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { androidx.compose.material3.CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimaryContainer, ) Text( text = stringResource(R.string.ai_translating_lyrics), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, ) } } } is LyricsTranslationHelper.TranslationStatus.Error -> { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.errorContainer, ), shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( painter = painterResource(R.drawable.error), contentDescription = null, tint = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.size(16.dp), ) Text( text = status.message, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onErrorContainer, ) } } } is LyricsTranslationHelper.TranslationStatus.Success -> { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.tertiaryContainer, ), shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( painter = painterResource(R.drawable.check), contentDescription = null, tint = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.size(16.dp), ) Text( text = stringResource(R.string.ai_lyrics_translated), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onTertiaryContainer, ) } } } is LyricsTranslationHelper.TranslationStatus.Idle -> { // No status display } } } if (lyrics == LYRICS_NOT_FOUND) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Text( text = stringResource(R.string.lyrics_not_found), fontSize = 20.sp, color = MaterialTheme.colorScheme.secondary, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold, modifier = Modifier.alpha(0.5f), ) } } else { LazyColumn( state = lazyListState, contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Top) .add(WindowInsets(top = maxHeight / 3, bottom = maxHeight / 2)) .asPaddingValues(), modifier = Modifier .fadingEdge(vertical = 64.dp) .nestedScroll( remember { object : NestedScrollConnection { override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { if (source == NestedScrollSource.UserInput) { isAutoScrollEnabled = false } if (!isSelectionModeActive) { // Only update preview time if not selecting lastPreviewTime = System.currentTimeMillis() } return super.onPostScroll(consumed, available, source) } override suspend fun onPostFling( consumed: Velocity, available: Velocity, ): Velocity { isAutoScrollEnabled = false if (!isSelectionModeActive) { // Only update preview time if not selecting lastPreviewTime = System.currentTimeMillis() } return super.onPostFling(consumed, available) } } }, ), ) { val displayedCurrentLineIndex = if (!isAutoScrollEnabled) { currentLineIndex } else { if (isSeeking || isSelectionModeActive) deferredCurrentLineIndex else currentLineIndex } // Show lyrics provider at the top, scrolling with content if (isLyricsProviderShown) { item { Text( text = "Lyrics from ${lyricsEntity?.provider}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), fontWeight = FontWeight.Medium, modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), ) } } if (lyrics == null) { item { ShimmerHost { repeat(10) { Box( contentAlignment = when (lyricsTextPosition) { LyricsPosition.LEFT -> Alignment.CenterStart LyricsPosition.CENTER -> Alignment.Center LyricsPosition.RIGHT -> Alignment.CenterEnd }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 4.dp), ) { TextPlaceholder() } } } } } else { val lyricsOffset = currentSong?.song?.lyricsOffset?.toLong() ?: 0L val effectivePlaybackPosition = currentPlaybackPosition + lyricsOffset itemsIndexed( items = lines, key = { index, item -> "$index-${item.time}" }, // Add stable key ) { index, item -> val isSelected = selectedIndices.contains(index) val itemModifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) // Clip for background .combinedClickable( enabled = true, onClick = { if (isSelectionModeActive) { // Toggle selection if (isSelected) { selectedIndices.remove(index) if (selectedIndices.isEmpty()) { isSelectionModeActive = false // Exit mode if last item deselected } } else { if (selectedIndices.size < maxSelectionLimit) { selectedIndices.add(index) } else { showMaxSelectionToast = true } } } else if (isSynced && changeLyrics && !isGuest) { // Professional seek action with smooth animation val lyricsOffset = currentSong?.song?.lyricsOffset ?: 0 playerConnection.seekTo((item.time - lyricsOffset).coerceAtLeast(0)) // Smooth slow scroll when clicking on lyrics (3 seconds) scope.launch { // First scroll to the clicked item without animation lazyListState.scrollToItem(index = index) // Then animate it to center position slowly val itemInfo = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } 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) { // Only animate if not already centered lazyListState.animateScrollBy( value = offset.toFloat(), animationSpec = tween(durationMillis = 1500), // Reduced to half speed ) } } } lastPreviewTime = 0L } }, onLongClick = { if (!isSelectionModeActive) { isSelectionModeActive = true selectedIndices.add(index) } else if (!isSelected && selectedIndices.size < maxSelectionLimit) { // If already in selection mode and item not selected, add it if below limit selectedIndices.add(index) } else if (!isSelected) { // If already at limit, show toast showMaxSelectionToast = true } }, ).background( if (isSelected && isSelectionModeActive) { MaterialTheme.colorScheme.primary.copy( alpha = 0.3f, ) } else { Color.Transparent }, ).padding(horizontal = 24.dp, vertical = 8.dp) // Check if this line shares the same time as the currently active line // This enables synchronized word-by-word animation for both main and background vocals val currentLineTime = if (displayedCurrentLineIndex >= 0 && displayedCurrentLineIndex < lines.size) { lines[displayedCurrentLineIndex].time } else { -1L } val isLineAtSameTime = item.time == currentLineTime val isActiveByIndex = index == displayedCurrentLineIndex val isActiveByTime = isLineAtSameTime && displayedCurrentLineIndex >= 0 val alpha by animateFloatAsState( targetValue = when { !isSynced || (isSelectionModeActive && isSelected) -> 1f isActiveByIndex || isActiveByTime -> 1f else -> 0.5f }, animationSpec = tween(durationMillis = 400), ) val scale by animateFloatAsState( targetValue = if (isActiveByIndex || isActiveByTime) 1.05f else 1f, animationSpec = tween(durationMillis = 400), ) // Determine alignment based on agent for multi-singer support val agentAlignment = when { item.isBackground -> { Alignment.CenterHorizontally } // Background always centered item.agent == "v1" -> { Alignment.Start } // First vocalist - left item.agent == "v2" -> { Alignment.End } // Second vocalist - right item.agent == "v1000" -> { Alignment.CenterHorizontally } // Group/chorus - center else -> { when (lyricsTextPosition) { LyricsPosition.LEFT -> Alignment.Start LyricsPosition.CENTER -> Alignment.CenterHorizontally LyricsPosition.RIGHT -> Alignment.End } } } val agentTextAlign = when { item.isBackground -> { TextAlign.Center } item.agent == "v1" -> { TextAlign.Left } item.agent == "v2" -> { TextAlign.Right } item.agent == "v1000" -> { TextAlign.Center } else -> { when (lyricsTextPosition) { LyricsPosition.LEFT -> TextAlign.Left LyricsPosition.CENTER -> TextAlign.Center LyricsPosition.RIGHT -> TextAlign.Right } } } // Smaller scale for background vocals val bgScale = if (item.isBackground) 0.85f else 1f Column( modifier = itemModifier.graphicsLayer { this.alpha = if (item.isBackground) alpha * 0.8f else alpha this.scaleX = scale * bgScale this.scaleY = scale * bgScale }, horizontalAlignment = agentAlignment, ) { // Use time-based active check to sync both main and background lines with same timestamp val isActiveLine = (isActiveByIndex || isActiveByTime) && isSynced val lineColor = if (isActiveLine) { if (item.isBackground) expressiveAccent.copy(alpha = 0.85f) else expressiveAccent } else { expressiveAccent.copy(alpha = if (item.isBackground) 0.5f else 0.7f) } val alignment = agentTextAlign val romanizedTextState by item.romanizedTextFlow.collectAsState() val romanizedText = romanizedTextState val isRomanizedAvailable = romanizedText != null val mainText = if (romanizeAsMain && isRomanizedAvailable) romanizedText else item.text val subText = if (romanizeAsMain && isRomanizedAvailable) item.text else romanizedText val hasWordTimings = if (romanizeAsMain && isRomanizedAvailable) false else item.words?.isNotEmpty() == true // Word-by-word animation styles if (hasWordTimings && lyricsAnimationStyle == LyricsAnimationStyle.NONE) { val styledText = buildAnnotatedString { item.words?.forEachIndexed { wordIndex, word -> val wordStartMs = (word.startTime * 1000).toLong() val wordEndMs = (word.endTime * 1000).toLong() val wordDuration = wordEndMs - wordStartMs val isWordActive = isActiveLine && effectivePlaybackPosition >= wordStartMs && effectivePlaybackPosition <= wordEndMs val hasWordPassed = isActiveLine && effectivePlaybackPosition > wordEndMs val transitionProgress = when { !isActiveLine -> { 0f } hasWordPassed -> { 1f } isWordActive && wordDuration > 0 -> { val elapsed = effectivePlaybackPosition - wordStartMs val linear = (elapsed.toFloat() / wordDuration).coerceIn(0f, 1f) linear * linear * (3f - 2f * linear) } else -> { 0f } } val wordAlpha = when { !isActiveLine -> 0.7f hasWordPassed -> 1f isWordActive -> 0.5f + (0.5f * transitionProgress) else -> 0.35f } val wordColor = expressiveAccent.copy(alpha = wordAlpha) val wordWeight = when { !isActiveLine -> FontWeight.Bold hasWordPassed -> FontWeight.Bold isWordActive -> FontWeight.ExtraBold else -> FontWeight.Medium } withStyle(style = SpanStyle(color = wordColor, fontWeight = wordWeight)) { append(word.text) } if (wordIndex < item.words.size - 1) append(" ") } } Text( text = styledText, fontSize = lyricsTextSize.sp, textAlign = alignment, lineHeight = (lyricsTextSize * lyricsLineSpacing).sp, ) } else if (hasWordTimings && lyricsAnimationStyle == LyricsAnimationStyle.FADE) { val styledText = buildAnnotatedString { item.words?.forEachIndexed { wordIndex, word -> val wordStartMs = (word.startTime * 1000).toLong() val wordEndMs = (word.endTime * 1000).toLong() val wordDuration = wordEndMs - wordStartMs val isWordActive = isActiveLine && effectivePlaybackPosition >= wordStartMs && effectivePlaybackPosition <= wordEndMs val hasWordPassed = isActiveLine && effectivePlaybackPosition > wordEndMs val fadeProgress = if (isWordActive && wordDuration > 0) { val timeElapsed = effectivePlaybackPosition - wordStartMs val linear = (timeElapsed.toFloat() / wordDuration.toFloat()).coerceIn(0f, 1f) // Smooth cubic easing linear * linear * (3f - 2f * linear) } else if (hasWordPassed) { 1f } else { 0f } val wordAlpha = when { !isActiveLine -> 0.55f hasWordPassed -> 1f isWordActive -> 0.4f + (0.6f * fadeProgress) else -> 0.4f } val wordColor = expressiveAccent.copy(alpha = wordAlpha) val wordWeight = when { !isActiveLine -> FontWeight.Bold hasWordPassed -> FontWeight.Bold isWordActive -> FontWeight.ExtraBold else -> FontWeight.Medium } // Enhanced shadow for active words val wordShadow = when { isWordActive && fadeProgress > 0.2f -> { Shadow( color = expressiveAccent.copy(alpha = 0.35f * fadeProgress), offset = Offset.Zero, blurRadius = 10f * fadeProgress, ) } hasWordPassed -> { Shadow( color = expressiveAccent.copy(alpha = 0.15f), offset = Offset.Zero, blurRadius = 6f, ) } else -> { null } } withStyle(style = SpanStyle(color = wordColor, fontWeight = wordWeight, shadow = wordShadow)) { append(word.text) } if (wordIndex < item.words.size - 1) append(" ") } } Text( text = styledText, fontSize = lyricsTextSize.sp, textAlign = alignment, lineHeight = (lyricsTextSize * lyricsLineSpacing).sp, ) } else if (hasWordTimings && lyricsAnimationStyle == LyricsAnimationStyle.GLOW) { val styledText = buildAnnotatedString { item.words?.forEachIndexed { wordIndex, word -> val wordStartMs = (word.startTime * 1000).toLong() val wordEndMs = (word.endTime * 1000).toLong() val wordDuration = wordEndMs - wordStartMs val isWordActive = isActiveLine && effectivePlaybackPosition in wordStartMs..wordEndMs val hasWordPassed = isActiveLine && effectivePlaybackPosition > wordEndMs val fillProgress = if (isWordActive && wordDuration > 0) { val linear = ((effectivePlaybackPosition - wordStartMs).toFloat() / wordDuration) .coerceIn( 0f, 1f, ) linear * linear * (3f - 2f * linear) } else if (hasWordPassed) { 1f } else { 0f } val glowIntensity = fillProgress * fillProgress val brightness = 0.45f + (0.55f * fillProgress) val wordColor = when { !isActiveLine -> expressiveAccent.copy(alpha = 0.5f) isWordActive || hasWordPassed -> expressiveAccent.copy(alpha = brightness) else -> expressiveAccent.copy(alpha = 0.35f) } val wordWeight = when { !isActiveLine -> FontWeight.Bold isWordActive -> FontWeight.ExtraBold hasWordPassed -> FontWeight.Bold else -> FontWeight.Medium } val wordShadow = if (isWordActive && glowIntensity > 0.05f) { Shadow( color = expressiveAccent.copy(alpha = 0.5f + (0.3f * glowIntensity)), offset = Offset.Zero, blurRadius = 16f + (12f * glowIntensity), ) } else if (hasWordPassed) { Shadow( color = expressiveAccent.copy(alpha = 0.25f), offset = Offset.Zero, blurRadius = 8f, ) } else { null } withStyle(style = SpanStyle(color = wordColor, fontWeight = wordWeight, shadow = wordShadow)) { append(word.text) } if (wordIndex < item.words.size - 1) append(" ") } } Text( text = styledText, fontSize = lyricsTextSize.sp, textAlign = alignment, lineHeight = (lyricsTextSize * lyricsLineSpacing).sp, ) } else if (hasWordTimings && lyricsAnimationStyle == LyricsAnimationStyle.SLIDE) { val styledText = buildAnnotatedString { item.words?.forEachIndexed { wordIndex, word -> val wordStartMs = (word.startTime * 1000).toLong() val wordEndMs = (word.endTime * 1000).toLong() val wordDuration = wordEndMs - wordStartMs val isWordActive = isActiveLine && effectivePlaybackPosition >= wordStartMs && effectivePlaybackPosition < wordEndMs val hasWordPassed = (isActiveLine && effectivePlaybackPosition >= wordEndMs) || (!isActiveLine && item.time < currentLineTime) if (isWordActive && wordDuration > 0) { val timeElapsed = effectivePlaybackPosition - wordStartMs val fillProgress = (timeElapsed.toFloat() / wordDuration.toFloat()).coerceIn(0f, 1f) val breatheValue = (timeElapsed % 3000) / 3000f val breatheEffect = ( kotlin.math.sin( breatheValue * Math.PI.toFloat() * 2f, ) * 0.03f ).coerceIn(0f, 0.03f) val glowIntensity = (0.3f + fillProgress * 0.7f + breatheEffect).coerceIn(0f, 1.1f) val slideBrush = Brush.horizontalGradient( 0.0f to expressiveAccent, (fillProgress * 0.95f).coerceIn(0f, 1f) to expressiveAccent, fillProgress to expressiveAccent.copy(alpha = 0.9f), (fillProgress + 0.02f).coerceIn(0f, 1f) to expressiveAccent.copy(alpha = 0.5f), (fillProgress + 0.08f).coerceIn(0f, 1f) to expressiveAccent.copy(alpha = 0.35f), 1.0f to expressiveAccent.copy(alpha = 0.35f), ) withStyle( style = SpanStyle( brush = slideBrush, fontWeight = FontWeight.ExtraBold, shadow = Shadow( color = expressiveAccent.copy(alpha = 0.4f * glowIntensity), offset = Offset(0f, 0f), blurRadius = 14f + (4f * fillProgress), ), ), ) { append(word.text) } } else if (hasWordPassed && isActiveLine) { withStyle( style = SpanStyle( color = expressiveAccent, fontWeight = FontWeight.Bold, shadow = Shadow( color = expressiveAccent.copy(alpha = 0.4f), offset = Offset(0f, 0f), blurRadius = 12f, ), ), ) { append(word.text) } } else { val wordColor = if (!isActiveLine) lineColor else expressiveAccent.copy(alpha = 0.35f) withStyle(style = SpanStyle(color = wordColor, fontWeight = FontWeight.Medium)) { append(word.text) } } if (wordIndex < item.words.size - 1) append(" ") } } Text( text = styledText, fontSize = lyricsTextSize.sp, textAlign = alignment, lineHeight = ( lyricsTextSize * lyricsLineSpacing ).sp, ) } else if (hasWordTimings && lyricsAnimationStyle == LyricsAnimationStyle.KARAOKE) { val styledText = buildAnnotatedString { item.words?.forEachIndexed { wordIndex, word -> val wordStartMs = (word.startTime * 1000).toLong() val wordEndMs = (word.endTime * 1000).toLong() val wordDuration = wordEndMs - wordStartMs val isWordActive = isActiveLine && effectivePlaybackPosition >= wordStartMs && effectivePlaybackPosition < wordEndMs val hasWordPassed = (isActiveLine && effectivePlaybackPosition >= wordEndMs) || (!isActiveLine && item.time < currentLineTime) if (isWordActive && wordDuration > 0) { val timeElapsed = effectivePlaybackPosition - wordStartMs val linearProgress = (timeElapsed.toFloat() / wordDuration.toFloat()).coerceIn(0f, 1f) // Smoother easing curve for more natural fill animation val fillProgress = linearProgress * linearProgress * (3f - 2f * linearProgress) // Enhanced glow intensity calculation val glowIntensity = fillProgress * fillProgress val wordBrush = Brush.horizontalGradient( 0.0f to expressiveAccent.copy(alpha = 0.4f), (fillProgress * 0.6f).coerceIn(0f, 1f) to expressiveAccent.copy(alpha = 0.75f), (fillProgress * 0.85f).coerceIn(0f, 1f) to expressiveAccent.copy(alpha = 0.95f), fillProgress to expressiveAccent, (fillProgress + 0.03f).coerceIn(0f, 1f) to expressiveAccent.copy(alpha = 0.85f), (fillProgress + 0.1f).coerceIn(0f, 1f) to expressiveAccent.copy(alpha = 0.5f), 1.0f to expressiveAccent.copy(alpha = if (fillProgress >= 0.9f) 0.95f else 0.4f), ) // Improved shadow with better glow effect val wordShadow = Shadow( color = expressiveAccent.copy(alpha = 0.5f + (0.3f * glowIntensity)), offset = Offset.Zero, blurRadius = 16f + (12f * glowIntensity), ) withStyle( style = SpanStyle( brush = wordBrush, fontWeight = FontWeight.ExtraBold, shadow = wordShadow, ), ) { append(word.text) } } else if (hasWordPassed && isActiveLine) { // Completed words with subtle glow withStyle( style = SpanStyle( color = expressiveAccent, fontWeight = FontWeight.Bold, shadow = Shadow( color = expressiveAccent.copy(alpha = 0.25f), offset = Offset.Zero, blurRadius = 8f, ), ), ) { append(word.text) } } else { // Inactive words val wordColor = if (!isActiveLine) lineColor else expressiveAccent.copy(alpha = 0.4f) withStyle(style = SpanStyle(color = wordColor, fontWeight = FontWeight.Medium)) { append(word.text) } } if (wordIndex < item.words.size - 1) append(" ") } } Text( text = styledText, fontSize = lyricsTextSize.sp, textAlign = alignment, lineHeight = ( lyricsTextSize * lyricsLineSpacing ).sp, ) } else if (hasWordTimings && lyricsAnimationStyle == LyricsAnimationStyle.APPLE) { val styledText = buildAnnotatedString { item.words?.forEachIndexed { wordIndex, word -> val wordStartMs = (word.startTime * 1000).toLong() val wordEndMs = (word.endTime * 1000).toLong() val wordDuration = wordEndMs - wordStartMs val isWordActive = isActiveLine && effectivePlaybackPosition >= wordStartMs && effectivePlaybackPosition < wordEndMs val hasWordPassed = (isActiveLine && effectivePlaybackPosition >= wordEndMs) || (!isActiveLine && item.time < currentLineTime) val rawProgress = if (isWordActive && wordDuration > 0) { val elapsed = effectivePlaybackPosition - wordStartMs (elapsed.toFloat() / wordDuration).coerceIn(0f, 1f) } else if (hasWordPassed) { 1f } else { 0f } // Smooth cubic easing for natural animation val smoothProgress = rawProgress * rawProgress * (3f - 2f * rawProgress) val wordAlpha = when { !isActiveLine -> 0.55f hasWordPassed -> 1f isWordActive -> 0.55f + (0.45f * smoothProgress) else -> 0.4f } val wordColor = expressiveAccent.copy(alpha = wordAlpha) val wordWeight = when { !isActiveLine -> FontWeight.SemiBold hasWordPassed -> FontWeight.Bold isWordActive -> FontWeight.ExtraBold else -> FontWeight.Normal } // Enhanced shadow with better glow intensity val glowIntensity = smoothProgress * smoothProgress val wordShadow = when { isWordActive -> { Shadow( color = expressiveAccent.copy(alpha = 0.2f + (0.4f * glowIntensity)), offset = Offset.Zero, blurRadius = 10f + (12f * glowIntensity), ) } hasWordPassed && isActiveLine -> { Shadow( color = expressiveAccent.copy(alpha = 0.2f), offset = Offset.Zero, blurRadius = 8f, ) } else -> { null } } withStyle(style = SpanStyle(color = wordColor, fontWeight = wordWeight, shadow = wordShadow)) { append(word.text) } if (wordIndex < item.words.size - 1) append(" ") } } Text( text = styledText, fontSize = lyricsTextSize.sp, textAlign = alignment, lineHeight = ( lyricsTextSize * lyricsLineSpacing ).sp, ) } else if (isActiveLine && lyricsGlowEffect) { // Initial animation for glow fill from left to right val fillProgress = remember { Animatable(0f) } // Continuous pulsing animation for the glow val pulseProgress = remember { Animatable(0f) } LaunchedEffect(index) { fillProgress.snapTo(0f) fillProgress.animateTo( targetValue = 1f, animationSpec = tween( durationMillis = 1200, easing = FastOutSlowInEasing, ), ) } // Continuous slow pulsing animation 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 // Combine fill animation with subtle pulse val pulseEffect = (kotlin.math.sin(pulse * Math.PI.toFloat()) * 0.15f).coerceIn(0f, 0.15f) val glowIntensity = (fill + pulseEffect).coerceIn(0f, 1.2f) // Create left-to-right gradient fill with glow val glowBrush = 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 = Shadow( color = expressiveAccent.copy(alpha = 0.8f * glowIntensity), offset = Offset(0f, 0f), blurRadius = 28f * (1f + pulseEffect), ), brush = glowBrush, ), ) { append(mainText) } } // Single smooth bounce animation val bounceScale = if (fill < 0.3f) { // Gentler rise during fill 1f + (kotlin.math.sin(fill * 3.33f * Math.PI.toFloat()) * 0.03f) } else { // Hold at normal scale 1f } Text( text = styledText, fontSize = lyricsTextSize.sp, textAlign = alignment, fontWeight = FontWeight.ExtraBold, lineHeight = (lyricsTextSize * lyricsLineSpacing).sp, modifier = Modifier .graphicsLayer { scaleX = bounceScale scaleY = bounceScale }, ) } else if (isActiveLine && !lyricsGlowEffect) { // Active line without glow effect - just bold text Text( text = mainText, fontSize = lyricsTextSize.sp, color = expressiveAccent, textAlign = alignment, fontWeight = FontWeight.ExtraBold, lineHeight = (lyricsTextSize * lyricsLineSpacing).sp, ) } else { // Inactive line Text( text = mainText, fontSize = lyricsTextSize.sp, color = lineColor, textAlign = alignment, fontWeight = FontWeight.Bold, lineHeight = (lyricsTextSize * lyricsLineSpacing).sp, ) } if (currentSong?.romanizeLyrics == true && enabledLanguages.isNotEmpty()) { // Show secondary text (romanized or original) if available subText?.let { text -> Text( text = text, fontSize = 18.sp, color = expressiveAccent.copy(alpha = 0.6f), textAlign = when (lyricsTextPosition) { LyricsPosition.LEFT -> TextAlign.Left LyricsPosition.CENTER -> TextAlign.Center LyricsPosition.RIGHT -> TextAlign.Right }, fontWeight = FontWeight.Normal, modifier = Modifier.padding(top = 2.dp), ) } } // Show translated text if available val translatedText by item.translatedTextFlow.collectAsState() translatedText?.let { translated -> Text( text = translated, fontSize = 16.sp, color = expressiveAccent.copy(alpha = 0.5f), textAlign = when (lyricsTextPosition) { LyricsPosition.LEFT -> TextAlign.Left LyricsPosition.CENTER -> TextAlign.Center LyricsPosition.RIGHT -> TextAlign.Right }, fontWeight = FontWeight.Normal, modifier = Modifier.padding(top = 4.dp), ) } } } } } // Action buttons are now in the bottom bar // Removed the more button from bottom - it's now in the top header } AnimatedVisibility( visible = isSelectionModeActive, enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), ) { AnimatedVisibility( visible = !isAutoScrollEnabled && isSynced && !isSelectionModeActive, enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), ) { FilledTonalButton(onClick = { scope.launch { performSmoothPageScroll(currentLineIndex, 1500) } isAutoScrollEnabled = true }) { Icon( painter = painterResource(id = R.drawable.sync), contentDescription = stringResource(R.string.auto_scroll), modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(text = stringResource(R.string.auto_scroll)) } } AnimatedVisibility( visible = isSelectionModeActive, enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { FilledTonalButton( onClick = { isSelectionModeActive = false selectedIndices.clear() }, ) { Icon( painter = painterResource(id = R.drawable.close), contentDescription = stringResource(R.string.cancel), modifier = Modifier.size(20.dp), ) } FilledTonalButton( 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(), ) { Icon( painter = painterResource(id = R.drawable.share), contentDescription = stringResource(R.string.share_selected), modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(text = stringResource(R.string.share)) } } } } if (showProgressDialog) { BasicAlertDialog(onDismissRequest = { /* Don't dismiss */ }) { Card( // Use Card for better styling 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) { val (lyricsText, songTitle, artists) = shareDialogData!! // Renamed 'lyrics' to 'lyricsText' for clarity BasicAlertDialog(onDismissRequest = { showShareDialog = false }) { Card( shape = MaterialTheme.shapes.medium, 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 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}" // Use the potentially multi-line lyricsText here putExtra( Intent.EXTRA_TEXT, "\"$lyricsText\"\n\n$songTitle - $artists\n$songLink", ) } context.startActivity( Intent.createChooser( shareIntent, shareLyricsStr, ), ) showShareDialog = false }.padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( painter = painterResource(id = R.drawable.share), // Use new share icon 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 Row( modifier = Modifier .fillMaxWidth() .clickable { // Pass the potentially multi-line lyrics to the color picker shareDialogData = Triple(lyricsText, songTitle, artists) showColorPickerDialog = true showShareDialog = false }.padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( painter = painterResource(id = R.drawable.share), // Use new share icon 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 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 { showShareDialog = false } .padding(vertical = 8.dp, horizontal = 12.dp), ) } } } } } if (showColorPickerDialog && shareDialogData != null) { val (lyricsText, songTitle, artists) = shareDialogData!! val coverUrl = mediaMetadata?.thumbnailUrl val paletteColors = remember { mutableStateListOf() } var previewBackgroundStyle by remember { mutableStateOf(LyricsBackgroundStyle.SOLID) } val previewCardWidth = configuration.containerDpSize.width * 0.90f val previewPadding = 20.dp * 2 val previewBoxPadding = 28.dp * 2 val previewAvailableWidth = previewCardWidth - previewPadding - previewBoxPadding val previewBoxHeight = 340.dp val headerFooterEstimate = (48.dp + 14.dp + 16.dp + 20.dp + 8.dp + 28.dp * 2) val previewAvailableHeight = previewBoxHeight - headerFooterEstimate val lyricsTextAlign = when (lyricsTextPosition) { LyricsPosition.LEFT -> TextAlign.Left LyricsPosition.CENTER -> TextAlign.Center LyricsPosition.RIGHT -> TextAlign.Right } val textStyleForMeasurement = TextStyle( color = previewTextColor, fontWeight = FontWeight.Bold, textAlign = lyricsTextAlign, ) val textMeasurer = rememberTextMeasurer() rememberAdjustedFontSize( text = lyricsText, maxWidth = previewAvailableWidth, maxHeight = previewAvailableHeight, density = density, initialFontSize = 50.sp, minFontSize = 22.sp, style = textStyleForMeasurement, textMeasurer = textMeasurer, ) LaunchedEffect(coverUrl) { if (coverUrl != null) { withContext(Dispatchers.IO) { try { val loader = ImageLoader(context) val req = ImageRequest .Builder(context) .data(coverUrl) .allowHardware(false) .build() val result = loader.execute(req) val bmp = result.image?.toBitmap() if (bmp != null) { val palette = Palette.from(bmp).generate() val swatches = palette.swatches.sortedByDescending { it.population } val colors = swatches .map { Color(it.rgb) } .filter { color -> val hsv = FloatArray(3) android.graphics.Color.colorToHSV(color.toArgb(), hsv) hsv[1] > 0.2f } paletteColors.clear() paletteColors.addAll(colors.take(5)) } } catch (_: Exception) { } } } } BasicAlertDialog(onDismissRequest = { showColorPickerDialog = false }) { Card( shape = RoundedCornerShape(20.dp), modifier = Modifier .fillMaxWidth() .padding(20.dp), ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .verticalScroll(rememberScrollState()) .padding(20.dp), ) { Text( text = stringResource(id = R.string.customize_colors), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(12.dp)) Text(text = stringResource(id = R.string.player_background_style), style = MaterialTheme.typography.titleMedium) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp), ) { LyricsBackgroundStyle.entries.forEach { style -> val label = when (style) { LyricsBackgroundStyle.SOLID -> stringResource(R.string.player_background_solid) LyricsBackgroundStyle.BLUR -> stringResource(R.string.player_background_blur) LyricsBackgroundStyle.GRADIENT -> stringResource(R.string.gradient) } val selected = previewBackgroundStyle == style androidx.compose.material3.FilterChip( selected = selected, onClick = { previewBackgroundStyle = style }, label = { Text(label) }, ) } } Box( modifier = Modifier .fillMaxWidth() .aspectRatio(1f) .padding(8.dp) .clip(RoundedCornerShape(12.dp)), ) { LyricsImageCard( lyricText = lyricsText, mediaMetadata = mediaMetadata ?: return@Box, backgroundColor = previewBackgroundColor, backgroundStyle = previewBackgroundStyle, textColor = previewTextColor, secondaryTextColor = previewSecondaryTextColor, textAlign = lyricsTextAlign, ) } Spacer(modifier = Modifier.height(18.dp)) Text(text = stringResource(id = R.string.background_color), style = MaterialTheme.typography.titleMedium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) { ( paletteColors + listOf( Color(0xFF242424), Color(0xFF121212), Color.White, Color.Black, Color(0xFFF5F5F5), ) ).distinct().take(8).forEach { color -> Box( modifier = Modifier .size(32.dp) .background(color, shape = RoundedCornerShape(8.dp)) .clickable { previewBackgroundColor = color } .border( 2.dp, if (previewBackgroundColor == color ) { MaterialTheme.colorScheme.primary } else { Color.Transparent }, RoundedCornerShape(8.dp), ), ) } } Text(text = stringResource(id = R.string.text_color), style = MaterialTheme.typography.titleMedium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) { (paletteColors + listOf(Color.White, Color.Black, Color(0xFF1DB954))).distinct().take(8).forEach { color -> Box( modifier = Modifier .size(32.dp) .background(color, shape = RoundedCornerShape(8.dp)) .clickable { previewTextColor = color } .border( 2.dp, if (previewTextColor == color) MaterialTheme.colorScheme.primary else Color.Transparent, RoundedCornerShape(8.dp), ), ) } } Text(text = stringResource(id = R.string.secondary_text_color), style = MaterialTheme.typography.titleMedium) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(vertical = 8.dp)) { ( paletteColors.map { it.copy(alpha = 0.7f) } + listOf(Color.White.copy(alpha = 0.7f), Color.Black.copy(alpha = 0.7f), Color(0xFF1DB954)) ).distinct().take(8).forEach { color -> Box( modifier = Modifier .size(32.dp) .background(color, shape = RoundedCornerShape(8.dp)) .clickable { previewSecondaryTextColor = color } .border( 2.dp, if (previewSecondaryTextColor == color ) { MaterialTheme.colorScheme.primary } else { Color.Transparent }, RoundedCornerShape(8.dp), ), ) } } Spacer(modifier = Modifier.height(12.dp)) Button( onClick = { showColorPickerDialog = false showProgressDialog = true scope.launch { try { val screenWidth = configuration.containerSize.width val screenHeight = configuration.containerSize.height 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 = previewBackgroundColor.toArgb(), backgroundStyle = previewBackgroundStyle, textColor = previewTextColor.toArgb(), secondaryTextColor = previewSecondaryTextColor.toArgb(), lyricsAlignment = when (lyricsTextPosition) { LyricsPosition.LEFT -> Layout.Alignment.ALIGN_NORMAL LyricsPosition.CENTER -> Layout.Alignment.ALIGN_CENTER LyricsPosition.RIGHT -> Layout.Alignment.ALIGN_OPPOSITE }, ) val timestamp = System.currentTimeMillis() val filename = "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, shareLyricsStr)) } catch (e: Exception) { Toast .makeText( context, String.format(failedToCreateImageTemplate, e.message ?: ""), Toast.LENGTH_SHORT, ).show() } finally { showProgressDialog = false } } }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(id = R.string.share)) } } } } } // إغلاق else block } } // Professional page animation constants inspired by Metrolist design - slower for smoothness private const val METROLIST_AUTO_SCROLL_DURATION = 1500L // Much slower auto-scroll for smooth transitions private const val METROLIST_INITIAL_SCROLL_DURATION = 1000L // Slower initial positioning private const val METROLIST_SEEK_DURATION = 800L // Slower user interaction private const val METROLIST_FAST_SEEK_DURATION = 600L // Less aggressive seeking // Lyrics constants val LyricsPreviewTime = 2.seconds ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/LyricsImageCard.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import android.annotation.SuppressLint 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.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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape 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.graphics.Color import androidx.compose.ui.layout.ContentScale 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.em import androidx.compose.ui.unit.sp import coil3.compose.rememberAsyncImagePainter import coil3.request.ImageRequest import coil3.request.crossfade import com.metrolist.music.R import com.metrolist.music.models.MediaMetadata import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.toArgb import androidx.palette.graphics.Palette import coil3.ImageLoader import coil3.request.allowHardware import coil3.toBitmap import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import androidx.compose.ui.res.stringResource @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 < 50 -> initialFontSize text.length < 100 -> (initialFontSize.value * 0.8f).sp text.length < 200 -> (initialFontSize.value * 0.6f).sp else -> (initialFontSize.value * 0.5f).sp } mutableStateOf(initialSize) } LaunchedEffect(key1 = text, key2 = maxWidth, key3 = maxHeight) { val targetWidthPx = with(density) { maxWidth.toPx() * 0.92f } val targetHeightPx = with(density) { maxHeight.toPx() * 0.92f } if (text.isBlank()) { calculatedFontSize = minFontSize return@LaunchedEffect } if (text.length < 20) { val largerSize = (initialFontSize.value * 1.1f).sp val result = measurer.measure( text = AnnotatedString(text), style = style.copy(fontSize = largerSize) ) if (result.size.width <= targetWidthPx && result.size.height <= targetHeightPx) { calculatedFontSize = largerSize return@LaunchedEffect } } else if (text.length < 30) { val largerSize = (initialFontSize.value * 0.9f).sp val result = measurer.measure( text = AnnotatedString(text), style = style.copy(fontSize = largerSize) ) if (result.size.width <= targetWidthPx && result.size.height <= targetHeightPx) { calculatedFontSize = largerSize return@LaunchedEffect } } var minSize = minFontSize.value var maxSize = initialFontSize.value 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) ) 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 } enum class LyricsBackgroundStyle { SOLID, BLUR, GRADIENT } @SuppressLint("UnusedBoxWithConstraintsScope") @Composable fun LyricsImageCard( lyricText: String, mediaMetadata: MediaMetadata, darkBackground: Boolean = true, backgroundColor: Color? = null, backgroundStyle: LyricsBackgroundStyle = LyricsBackgroundStyle.SOLID, textColor: Color? = null, secondaryTextColor: Color? = null, textAlign: TextAlign = TextAlign.Center ) { val context = LocalContext.current val density = LocalDensity.current val cardCornerRadius = 20.dp val padding = 28.dp val coverArtSize = 64.dp val defaultBgColor = if (darkBackground) Color(0xFF121212) else Color(0xFFF5F5F5) val backgroundSolidColor = backgroundColor ?: defaultBgColor val mainTextColor = textColor ?: if (darkBackground) Color.White else Color.Black val secondaryColor = secondaryTextColor ?: if (darkBackground) Color.White.copy(alpha = 0.7f) else Color.Black.copy(alpha = 0.7f) val painter = rememberAsyncImagePainter( ImageRequest.Builder(context) .data(mediaMetadata.thumbnailUrl) .crossfade(false) .build() ) // Calculate gradient colors if needed var gradientBrush by remember { mutableStateOf(null) } if (backgroundStyle == LyricsBackgroundStyle.GRADIENT) { LaunchedEffect(mediaMetadata.thumbnailUrl) { withContext(Dispatchers.IO) { try { val loader = ImageLoader(context) val req = ImageRequest.Builder(context).data(mediaMetadata.thumbnailUrl).allowHardware(false).build() val result = loader.execute(req) val bmp = result.image?.toBitmap() if (bmp != null) { val palette = Palette.from(bmp).generate() val vibrant = palette.getVibrantColor(defaultBgColor.toArgb()) val muted = palette.getMutedColor(defaultBgColor.toArgb()) val darkVibrant = palette.getDarkVibrantColor(defaultBgColor.toArgb()) val color1 = Color(vibrant) val color2 = Color(darkVibrant) gradientBrush = Brush.linearGradient( colors = listOf(color1, color2), tileMode = TileMode.Clamp ) } } catch (_: Exception) {} } } } Box( modifier = Modifier .background(Color.Black) // Base background .fillMaxSize(), contentAlignment = Alignment.Center ) { // Background Layer Box( modifier = Modifier.fillMaxSize() ) { when (backgroundStyle) { LyricsBackgroundStyle.SOLID -> { Box(modifier = Modifier.fillMaxSize().background(backgroundSolidColor)) } LyricsBackgroundStyle.BLUR -> { Image( painter = painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() .blur(50.dp) // High blur for background .background(Color.Black.copy(alpha = 0.3f)) // Overlay to ensure text readability ) } LyricsBackgroundStyle.GRADIENT -> { Box( modifier = Modifier .fillMaxSize() .background(gradientBrush ?: androidx.compose.ui.graphics.Brush.linearGradient(listOf(backgroundSolidColor, backgroundSolidColor))) ) } } } Box( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(cardCornerRadius)) // For the card itself, we can make it slightly transparent or match the background style // but usually the card IS the background cut out. // Here we simulate the card being transparent so the background shows through, // OR we redraw the background inside the card if we want the "card on background" look. // Based on previous code, the card had its own background. // Let's apply the same background logic to the card box. ) { when (backgroundStyle) { LyricsBackgroundStyle.SOLID -> { Box(modifier = Modifier.fillMaxSize().background(backgroundSolidColor)) } LyricsBackgroundStyle.BLUR -> { // For blur, we want the card to be a window to the blurred background? // Or have its own blurred background? // Typically "Share Lyrics" looks like a card on a background. // If we want the card to be seamless with the full image background, we can just use transparent. // But to ensure it looks like the generated image: Image( painter = painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() .blur(50.dp) .background(Color.Black.copy(alpha = 0.3f)) ) } LyricsBackgroundStyle.GRADIENT -> { Box( modifier = Modifier .fillMaxSize() .background(gradientBrush ?: androidx.compose.ui.graphics.Brush.linearGradient(listOf(backgroundSolidColor, backgroundSolidColor))) ) } } // Border Box( modifier = Modifier .fillMaxSize() .border(1.dp, mainTextColor.copy(alpha = 0.09f), RoundedCornerShape(cardCornerRadius)) ) Column( modifier = Modifier .fillMaxSize() .padding(padding), verticalArrangement = Arrangement.SpaceBetween ) { // Header: Cover + Title/Artist aligned left Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(bottom = 12.dp) ) { Image( painter = painter, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(coverArtSize) .clip(RoundedCornerShape(3.dp)) .border(1.dp, mainTextColor.copy(alpha = 0.16f), RoundedCornerShape(3.dp)) ) Spacer(modifier = Modifier.width(16.dp)) Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.Start, modifier = Modifier.weight(1f) ) { Text( text = mediaMetadata.title, color = mainTextColor, fontSize = 20.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(bottom = 2.dp) ) Text( text = mediaMetadata.artists.joinToString { it.name }, color = secondaryColor, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } // Lyrics text (centered) BoxWithConstraints( modifier = Modifier .weight(1f) .fillMaxWidth() .padding(vertical = 6.dp), contentAlignment = when (textAlign) { TextAlign.Left, TextAlign.Start -> Alignment.CenterStart TextAlign.Right, TextAlign.End -> Alignment.CenterEnd else -> Alignment.Center } ) { val availableWidth = maxWidth val availableHeight = maxHeight val textStyle = TextStyle( color = mainTextColor, fontWeight = FontWeight.Bold, textAlign = textAlign, letterSpacing = 0.005.em, ) val textMeasurer = rememberTextMeasurer() val initialSize = when { lyricText.length < 50 -> 24.sp lyricText.length < 100 -> 20.sp lyricText.length < 200 -> 17.sp lyricText.length < 300 -> 15.sp else -> 13.sp } val dynamicFontSize = rememberAdjustedFontSize( text = lyricText, maxWidth = availableWidth - 8.dp, maxHeight = availableHeight - 8.dp, density = density, initialFontSize = initialSize, minFontSize = 18.sp, style = textStyle, textMeasurer = textMeasurer ) Text( text = lyricText, style = textStyle.copy( fontSize = dynamicFontSize, lineHeight = dynamicFontSize.value.sp * 1.2f ), overflow = TextOverflow.Ellipsis, textAlign = textAlign, modifier = Modifier.fillMaxWidth() ) } // Footer Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { Box( modifier = Modifier .size(22.dp) .clip(RoundedCornerShape(50)) .background(secondaryColor), contentAlignment = Alignment.Center ) { Image( painter = painterResource(id = R.drawable.small_icon), contentDescription = null, modifier = Modifier .size(16.dp), colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(backgroundSolidColor) // Try to use a contrasting color, fallback to solid bg color ) } Spacer(modifier = Modifier.width(8.dp)) Text( text = stringResource(R.string.app_name), color = secondaryColor, fontSize = 14.sp, fontWeight = FontWeight.Bold ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/Material3SettingsGroup.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults 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 /** * A Material 3 Expressive style settings group component * @param title The title of the settings group * @param items List of settings items to display */ @Composable fun Material3SettingsGroup( title: String? = null, items: List, useLowContrast: Boolean = false ) { Column( modifier = Modifier .fillMaxWidth() ) { // Section title title?.let { Text( text = it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp, top = 8.dp) ) } // Settings items Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp) ) { items.forEachIndexed { index, item -> val shape = when { items.size == 1 -> RoundedCornerShape(24.dp) index == 0 -> RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 6.dp, bottomEnd = 6.dp) index == items.size - 1 -> RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 24.dp, bottomEnd = 24.dp) else -> RoundedCornerShape(6.dp) } Card( modifier = Modifier .fillMaxWidth() .animateContentSize(), shape = shape, colors = CardDefaults.cardColors( containerColor = if (!useLowContrast) { MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) } else { MaterialTheme.colorScheme.surfaceContainerLow } ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Material3SettingsItemRow(item = item) } } } } } /** * Individual settings item row with Material 3 styling */ @Composable private fun Material3SettingsItemRow( item: Material3SettingsItem ) { Row( modifier = Modifier .fillMaxWidth() .clickable( enabled = item.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.enabled) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) else 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.enabled) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) else 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.copy( color = if (!item.enabled) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) else MaterialTheme.colorScheme.onSurface ) ) { item.title() } // Description if provided item.description?.let { desc -> Spacer(modifier = Modifier.height(2.dp)) ProvideTextStyle( MaterialTheme.typography.bodyMedium.copy( color = if (!item.enabled) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) else MaterialTheme.colorScheme.onSurfaceVariant ) ) { desc() } } } // Trailing content item.trailingContent?.let { trailing -> Spacer(modifier = Modifier.width(8.dp)) trailing() } } } /** * Data class for Material 3 settings item */ data class Material3SettingsItem( 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 enabled: Boolean = true, val onClick: (() -> Unit)? = null ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/Menu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.animation.animateContentSize import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardColors import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun Material3MenuGroup( items: List ) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp) ) { items.forEachIndexed { index, item -> val shape = when { items.size == 1 -> RoundedCornerShape(24.dp) index == 0 -> RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 6.dp, bottomEnd = 6.dp) index == items.size - 1 -> RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 24.dp, bottomEnd = 24.dp) else -> RoundedCornerShape(6.dp) } Card( modifier = Modifier .fillMaxWidth() .animateContentSize(), shape = shape, colors = item.cardColors ?: CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Material3MenuItemRow(item = item) } } } } @Composable private fun Material3MenuItemRow( item: Material3MenuItemData ) { Row( modifier = Modifier .fillMaxWidth() .clickable( enabled = item.onClick != null, onClick = { item.onClick?.invoke() } ) .padding(horizontal = 20.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically ) { item.icon?.let { icon -> icon() Spacer(modifier = Modifier.width(16.dp)) } Column( modifier = Modifier.weight(1f) ) { ProvideTextStyle(MaterialTheme.typography.titleMedium) { item.title() } item.description?.let { desc -> Spacer(modifier = Modifier.height(2.dp)) ProvideTextStyle( MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onSurfaceVariant ) ) { desc() } } } item.trailingContent?.let { trailing -> Spacer(modifier = Modifier.width(8.dp)) trailing() } } } data class Material3MenuItemData( val icon: (@Composable () -> Unit)? = null, val title: @Composable () -> Unit, val description: (@Composable () -> Unit)? = null, val onClick: (() -> Unit)? = null, val cardColors: CardColors? = null, val trailingContent: (@Composable () -> Unit)? = null ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/NavigationTile.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.annotation.DrawableRes 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.padding 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.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.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @Composable fun NavigationTile( title: String, @DrawableRes icon: Int, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier.padding(6.dp), ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .size(56.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceContainer) .clickable(onClick = onClick), ) { Icon( painter = painterResource(icon), contentDescription = null, ) } Text( text = title, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/NavigationTitle.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.foundation.BorderStroke 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.WindowInsets 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.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton 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.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.metrolist.music.R @Composable fun NavigationTitle( title: String, modifier: Modifier = Modifier, label: String? = null, thumbnail: (@Composable () -> Unit)? = null, onClick: (() -> Unit)? = null, onPlayAllClick: (() -> 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, ) } onPlayAllClick?.let { playAllClick -> OutlinedButton( onClick = playAllClick, shape = RoundedCornerShape(12.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)), colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.primary ), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 2.dp), modifier = Modifier .height(24.dp) ) { Text( text = stringResource(R.string.play_all), style = MaterialTheme.typography.labelSmall ) } } if (onClick != null) { Icon( painter = painterResource(R.drawable.arrow_forward), contentDescription = null, tint = MaterialTheme.colorScheme.primary ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/NewMenuComponents.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween 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.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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider 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.graphics.Color 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 // Enhanced Action Button - Material 3 Expressive Design @Composable fun NewActionButton( icon: @Composable () -> Unit, text: String, onClick: @Composable () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant, contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, ) { val animatedBackground by animateColorAsState( targetValue = if (enabled) backgroundColor else backgroundColor.copy(alpha = 0.5f), animationSpec = tween(200), label = "background", ) val animatedContent by animateColorAsState( targetValue = if (enabled) contentColor else contentColor.copy(alpha = 0.5f), animationSpec = tween(200), label = "content", ) var performAction by remember { mutableStateOf(false) } if (performAction) { onClick() LaunchedEffect(Unit) { performAction = false } } Card( modifier = modifier .clickable(enabled = enabled) { performAction = true }, colors = CardDefaults.cardColors( containerColor = animatedBackground, ), shape = RoundedCornerShape(16.dp), elevation = CardDefaults.cardElevation(), ) { Column( modifier = Modifier .fillMaxWidth() .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Box( modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center, ) { icon() } Spacer(modifier = Modifier.height(6.dp)) Text( text = text, style = MaterialTheme.typography.labelMedium, color = animatedContent, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee(), ) } } } // Enhanced Menu Item - Material 3 Expressive Design @Composable fun NewMenuItem( modifier: Modifier = Modifier, headlineContent: @Composable () -> Unit, leadingContent: @Composable (() -> Unit)? = null, trailingContent: @Composable (() -> Unit)? = null, supportingContent: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null, enabled: Boolean = true, ) { androidx.compose.material3.ListItem( headlineContent = headlineContent, leadingContent = leadingContent, trailingContent = trailingContent, supportingContent = supportingContent, modifier = modifier .clickable(enabled = enabled) { onClick?.invoke() } .padding(horizontal = 4.dp), tonalElevation = 0.dp, ) } // Enhanced Menu Section Header - Material 3 Expressive Design @Composable fun NewMenuSectionHeader( text: String, modifier: Modifier = Modifier, ) { Text( text = text, style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.SemiBold, fontSize = 16.sp, ), color = MaterialTheme.colorScheme.primary, modifier = modifier.padding(horizontal = 20.dp, vertical = 12.dp), ) } // Enhanced Action Grid - Material 3 Expressive Design @Composable fun NewActionGrid( actions: List, modifier: Modifier = Modifier, columns: Int = 3, ) { val rows = actions.chunked(columns) Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp), ) { rows.forEach { row -> Row( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { row.forEach { action -> NewActionButton( icon = action.icon, text = action.text, onClick = action.onClick, modifier = Modifier.weight(1f), enabled = action.enabled, backgroundColor = if (action.backgroundColor != Color.Unspecified ) { action.backgroundColor } else { MaterialTheme.colorScheme.surfaceVariant }, contentColor = if (action.contentColor != Color.Unspecified ) { action.contentColor } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) } // Fill remaining space if row is not full repeat(columns - row.size) { Spacer(modifier = Modifier.weight(1f)) } } } } } // Enhanced Action Data Class data class NewAction( val icon: @Composable () -> Unit, val text: String, val onClick: @Composable () -> Unit, val enabled: Boolean = true, val backgroundColor: Color = Color.Unspecified, val contentColor: Color = Color.Unspecified, ) // Enhanced Menu Content - Material 3 Expressive Design @Composable fun NewMenuContent( headerContent: @Composable (() -> Unit)? = null, actionGrid: @Composable (() -> Unit)? = null, menuItems: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier, ) { Column( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), ) { // Header headerContent?.invoke() // Action Grid actionGrid?.invoke() // Divider if both header and actions exist if (headerContent != null && actionGrid != null) { HorizontalDivider( modifier = Modifier.padding(vertical = 16.dp), color = MaterialTheme.colorScheme.outlineVariant, ) } // Menu Items menuItems?.invoke() } } // Enhanced Icon Button - Material 3 Expressive Design @Composable fun NewIconButton( icon: @Composable () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant, contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, ) { val animatedBackground by animateColorAsState( targetValue = if (enabled) backgroundColor else backgroundColor.copy(alpha = 0.5f), animationSpec = tween(200), label = "background", ) val animatedContent by animateColorAsState( targetValue = if (enabled) contentColor else contentColor.copy(alpha = 0.5f), animationSpec = tween(200), label = "content", ) Card( modifier = modifier .clickable(enabled = enabled) { onClick() }, colors = CardDefaults.cardColors( containerColor = animatedBackground, ), shape = CircleShape, elevation = CardDefaults.cardElevation( defaultElevation = 2.dp, ), ) { Box( modifier = Modifier .size(48.dp) .padding(12.dp), contentAlignment = Alignment.Center, ) { icon() } } } // Enhanced Menu Container - Material 3 Expressive Design @Composable fun NewMenuContainer( content: @Composable () -> Unit, modifier: Modifier = Modifier, ) { Column( modifier = modifier .fillMaxWidth() .padding(horizontal = 20.dp) .padding(bottom = 32.dp), ) { content() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/PlayerSlider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/PlayingIndicator.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.music.R import com.metrolist.music.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, ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/Preference.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults 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.res.painterResource import androidx.compose.ui.unit.dp import com.metrolist.music.R @Composable @Deprecated("Use Material3SettingsGroup instead :)") 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( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .clickable( enabled = isEnabled && onClick != null, onClick = onClick ?: {}, ).alpha(if (isEnabled) 1f else 0.5f) .padding(horizontal = 16.dp, vertical = 16.dp), ) { if (icon != null) { Box( modifier = Modifier.padding(horizontal = 4.dp), ) { icon() } Spacer(Modifier.width(12.dp)) } Column( verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f), ) { ProvideTextStyle(MaterialTheme.typography.titleMedium) { title() } if (description != null) { Text( text = description, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary, ) } content?.invoke() } if (trailingContent != null) { Spacer(Modifier.width(12.dp)) trailingContent() } } } @Composable @Deprecated("Use the Switch component instead") 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 ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/RandomizeGridItem.kt ================================================ package com.metrolist.music.ui.component import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.metrolist.music.constants.ThumbnailCornerRadius @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun RandomizeGridItem( isLoading: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { // When isLoading is true, multiplier goes to 0 (moving dots to center) // When isLoading is false, multiplier goes to 1 (moving dots to corners) val dotOffsetMultiplier by animateFloatAsState( targetValue = if (isLoading) 0f else 1f, animationSpec = tween(durationMillis = 600), label = "dotOffset", ) val loadingAlpha by animateFloatAsState( targetValue = if (isLoading) 1f else 0f, animationSpec = tween(durationMillis = 400), label = "loadingAlpha", ) Box( modifier = modifier .aspectRatio(1f) .clip(RoundedCornerShape(ThumbnailCornerRadius)) .background(MaterialTheme.colorScheme.secondaryContainer) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { // Die Dots (5-pattern) val dotColor = MaterialTheme.colorScheme.onSecondaryContainer val dotSize = 14.dp val padding = 24.dp // Using a single Center alignment and offsetting FROM center ensures they // collapse TO center correctly. // Top Left Box( modifier = Modifier .align(Alignment.Center) .offset { IntOffset((-padding * dotOffsetMultiplier).roundToPx(), (-padding * dotOffsetMultiplier).roundToPx()) } .size(dotSize) .clip(CircleShape) .background(dotColor), ) // Top Right Box( modifier = Modifier .align(Alignment.Center) .offset { IntOffset((padding * dotOffsetMultiplier).roundToPx(), (-padding * dotOffsetMultiplier).roundToPx()) } .size(dotSize) .clip(CircleShape) .background(dotColor), ) // Center Box( modifier = Modifier .align(Alignment.Center) .size(dotSize) .clip(CircleShape) .background(dotColor), ) // Bottom Left Box( modifier = Modifier .align(Alignment.Center) .offset { IntOffset((-padding * dotOffsetMultiplier).roundToPx(), (padding * dotOffsetMultiplier).roundToPx()) } .size(dotSize) .clip(CircleShape) .background(dotColor), ) // Bottom Right Box( modifier = Modifier .align(Alignment.Center) .offset { IntOffset((padding * dotOffsetMultiplier).roundToPx(), (padding * dotOffsetMultiplier).roundToPx()) } .size(dotSize) .clip(CircleShape) .background(dotColor), ) // Loading Indicator overlay Box(modifier = Modifier.alpha(loadingAlpha)) { LoadingIndicator( modifier = Modifier.size(48.dp), color = MaterialTheme.colorScheme.onSecondaryContainer, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/ReleaseNotesCard.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.metrolist.music.R import com.metrolist.music.utils.Updater @Composable fun ReleaseNotesCard() { val releaseInfo = Updater.getCachedLatestRelease() ?: return 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)) Text( text = releaseInfo.description, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 2.dp) ) } } Spacer(modifier = Modifier.height(16.dp)) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/SearchBar.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.activity.compose.BackHandler 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.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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width 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.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable 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.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.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.dp import com.metrolist.music.constants.AppBarHeight @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? = null, colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ), scrollBehavior: TopAppBarScrollBehavior? = null, windowInsets: WindowInsets = WindowInsets.systemBars, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, focusRequester: FocusRequester = remember { FocusRequester() }, content: @Composable ColumnScope.() -> Unit = {}, ) { Box(modifier = modifier) { TopAppBar( title = { SearchBarInputField( query = query, onQueryChange = onQueryChange, onSearch = onSearch, active = active, onActiveChange = onActiveChange, enabled = enabled, placeholder = placeholder, // Icons are handled in navigationIcon and actions if preferred, or here for inline leadingIcon = null, trailingIcon = null, 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, ) }, navigationIcon = { if (leadingIcon != null) { leadingIcon() } }, actions = { if (trailingIcon != null) { trailingIcon() } }, colors = colors, scrollBehavior = scrollBehavior, windowInsets = windowInsets ) if (active) { Box( modifier = Modifier .fillMaxWidth() .padding(top = AppBarHeight + windowInsets.asPaddingValues().calculateTopPadding()) .background(MaterialTheme.colorScheme.surface) ) { Column { 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 = RoundedCornerShape(0.dp), colors = colors, contentPadding = PaddingValues(), container = {}, ) }, ) if (trailingIcon != null) { trailingIcon() Spacer(Modifier.width(SearchBarIconOffsetX)) } } } // Measurement specs val InputFieldHeight = 48.dp internal val TopAppBarVerticalPadding: Dp = 8.dp internal val TopAppBarHorizontalPadding: Dp = 12.dp val SearchBarIconOffsetX: Dp = 4.dp private const val AnimationDurationMillis: Int = 300 ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/SettingsSleepTimerDialog.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker import androidx.compose.material3.rememberTimePickerState 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.stringResource import androidx.compose.ui.unit.dp import com.metrolist.music.R import java.time.LocalTime import java.time.format.DateTimeFormatter import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Button import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.core.spring import androidx.compose.animation.core.Spring import androidx.compose.material3.ButtonGroup import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi fun decodeDayTimes(raw: String): MutableMap> { if (raw.isBlank()) return mutableMapOf() return raw .split(";") .mapNotNull { entry -> val parts = entry.split("=") if (parts.size != 2) return@mapNotNull null val dayIndex = parts[0].toIntOrNull() ?: return@mapNotNull null val times = parts[1].split("-") if (times.size != 2) return@mapNotNull null dayIndex to (times[0] to times[1]) }.toMap() .toMutableMap() } fun encodeDayTimes(map: Map>): String = map.entries.joinToString(";") { (day, times) -> "$day=${times.first}-${times.second}" } private const val DEFAULT_START = "22:00" private const val DEFAULT_END = "06:00" private val WEEKDAY_INDICES = 0..4 // Monday to Friday private val WEEKEND_INDICES = 5..6 // Saturday and Sunday @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun SleepTimerDialog( isVisible: Boolean, onDismiss: () -> Unit, onConfirm: ( repeat: String, startTime: String, endTime: String, customDays: List?, dayTimes: Map>, ) -> Unit, initialRepeat: String = "daily", initialStartTime: String = DEFAULT_START, initialEndTime: String = DEFAULT_END, initialCustomDays: List = listOf(0, 1, 2, 3, 4), initialDayTimes: Map> = emptyMap(), ) { if (!isVisible) return var selectedRepeat by remember { mutableStateOf( when (initialRepeat) { "weekdays", "weekends", "weekdays_weekends" -> "weekdays_weekends" else -> initialRepeat }, ) } var weekdaysEnabled by remember { // Restore from the previously saved repeat value mutableStateOf(initialRepeat in listOf("weekdays", "weekdays_weekends")) } var weekendsEnabled by remember { mutableStateOf(initialRepeat in listOf("weekends", "weekdays_weekends")) } var weekdaysStart by remember { mutableStateOf(initialDayTimes[WEEKDAY_INDICES.first]?.first ?: initialStartTime) } var weekdaysEnd by remember { mutableStateOf(initialDayTimes[WEEKDAY_INDICES.first]?.second ?: initialEndTime) } var weekendsStart by remember { mutableStateOf(initialDayTimes[WEEKEND_INDICES.first]?.first ?: initialStartTime) } var weekendsEnd by remember { mutableStateOf(initialDayTimes[WEEKEND_INDICES.first]?.second ?: initialEndTime) } var selectedStartTime by remember { mutableStateOf(initialStartTime) } var selectedEndTime by remember { mutableStateOf(initialEndTime) } var selectedDays by remember { mutableStateOf(initialCustomDays) } var dayTimesMap by remember { mutableStateOf(initialDayTimes) } var activeTimePicker by remember { mutableStateOf(null) } activeTimePicker?.let { pickerKey -> val isStart = pickerKey.contains("start") val title = if (isStart) { stringResource(R.string.sleep_timer_start_time) } else { stringResource(R.string.sleep_timer_end_time) } val currentTime = when (pickerKey) { "global_start" -> { selectedStartTime } "global_end" -> { selectedEndTime } "weekdays_start" -> { weekdaysStart } "weekdays_end" -> { weekdaysEnd } "weekends_start" -> { weekendsStart } "weekends_end" -> { weekendsEnd } else -> { val dayIdx = pickerKey.substringAfterLast("_").toIntOrNull() ?: 0 if (isStart) { dayTimesMap[dayIdx]?.first ?: DEFAULT_START } else { dayTimesMap[dayIdx]?.second ?: DEFAULT_END } } } SleepTimerTimePickerDialog( title = title, initialTime = currentTime, onDismiss = { activeTimePicker = null }, onConfirm = { time -> when (pickerKey) { "global_start" -> { selectedStartTime = time } "global_end" -> { selectedEndTime = time } "weekdays_start" -> { weekdaysStart = time } "weekdays_end" -> { weekdaysEnd = time } "weekends_start" -> { weekendsStart = time } "weekends_end" -> { weekendsEnd = time } else -> { val dayIdx = pickerKey.substringAfterLast("_").toIntOrNull() ?: 0 val existing = dayTimesMap[dayIdx] ?: (DEFAULT_START to DEFAULT_END) dayTimesMap = ( dayTimesMap + ( dayIdx to if (isStart) { existing.copy(first = time) } else { existing.copy(second = time) } ) ).toMutableMap() } } activeTimePicker = null }, ) } val dayLabelRes = listOf( R.string.sleep_timer_monday, R.string.sleep_timer_tuesday, R.string.sleep_timer_wednesday, R.string.sleep_timer_thursday, R.string.sleep_timer_friday, R.string.sleep_timer_saturday, R.string.sleep_timer_sunday, ) ListDialog(onDismiss = onDismiss) { item { Text( text = stringResource(R.string.sleep_timer), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) } item { SingleChoiceSegmentedButtonRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) { SegmentedButton( selected = selectedRepeat == "daily", onClick = { selectedRepeat = "daily" }, shape = SegmentedButtonDefaults.itemShape(index = 0, count = 3), label = { Text(stringResource(R.string.sleep_timer_daily)) }, ) SegmentedButton( selected = selectedRepeat == "weekdays_weekends", onClick = { selectedRepeat = "weekdays_weekends" if (!weekdaysEnabled && !weekendsEnabled) weekdaysEnabled = true }, shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3), label = { Text(stringResource(R.string.sleep_timer_weekdays_weekends)) }, ) SegmentedButton( selected = selectedRepeat == "custom", onClick = { selectedRepeat = "custom" }, shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3), label = { Text(stringResource(R.string.sleep_timer_custom)) }, ) } } item { AnimatedVisibility( visible = selectedRepeat == "daily", enter = expandVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeIn(), exit = shrinkVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeOut(), ) { ElevatedCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), ) { TimeRangeRow( startTime = selectedStartTime, endTime = selectedEndTime, onStartClick = { activeTimePicker = "global_start" }, onEndClick = { activeTimePicker = "global_end" }, ) } } } item { AnimatedVisibility( visible = selectedRepeat == "weekdays_weekends", enter = expandVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeIn(), exit = shrinkVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeOut(), ) { ElevatedCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { weekdaysEnabled = !weekdaysEnabled } .padding(vertical = 8.dp), ) { Text( text = stringResource(R.string.sleep_timer_weekdays), modifier = Modifier.weight(1f), ) Switch( checked = weekdaysEnabled, onCheckedChange = { weekdaysEnabled = it }, thumbContent = { Icon( painter = painterResource( if (weekdaysEnabled) R.drawable.check else R.drawable.close, ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) }, modifier = Modifier.scale(0.85f), ) } AnimatedVisibility( visible = weekdaysEnabled, enter = expandVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeIn(), exit = shrinkVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeOut(), ) { TimeRangeRow( startTime = weekdaysStart, endTime = weekdaysEnd, onStartClick = { activeTimePicker = "weekdays_start" }, onEndClick = { activeTimePicker = "weekdays_end" }, modifier = Modifier.padding(bottom = 4.dp), ) } HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { weekendsEnabled = !weekendsEnabled } .padding(vertical = 8.dp), ) { Text( text = stringResource(R.string.sleep_timer_weekends), modifier = Modifier.weight(1f), ) Switch( checked = weekendsEnabled, onCheckedChange = { weekendsEnabled = it }, thumbContent = { Icon( painter = painterResource( if (weekendsEnabled) R.drawable.check else R.drawable.close, ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) }, modifier = Modifier.scale(0.85f), ) } AnimatedVisibility( visible = weekendsEnabled, enter = expandVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeIn(), exit = shrinkVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeOut(), ) { TimeRangeRow( startTime = weekendsStart, endTime = weekendsEnd, onStartClick = { activeTimePicker = "weekends_start" }, onEndClick = { activeTimePicker = "weekends_end" }, modifier = Modifier.padding(bottom = 4.dp), ) } } } } } item { AnimatedVisibility( visible = selectedRepeat == "custom", enter = expandVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeIn(), exit = shrinkVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeOut(), ) { ElevatedCard( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), ) { Column(modifier = Modifier.fillMaxWidth()) { dayLabelRes.indices.forEach { index -> val isDaySelected = index in selectedDays val dayTimes = dayTimesMap[index] ?: (DEFAULT_START to DEFAULT_END) if (index > 0) { HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { selectedDays = if (index in selectedDays) selectedDays - index else selectedDays + index }.padding(vertical = 6.dp), ) { Text( text = stringResource(dayLabelRes[index]), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, ) Switch( checked = isDaySelected, onCheckedChange = { selectedDays = if (index in selectedDays) selectedDays - index else selectedDays + index }, thumbContent = { Icon( painter = painterResource( if (isDaySelected) R.drawable.check else R.drawable.close, ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) }, modifier = Modifier.scale(0.85f), ) } AnimatedVisibility( visible = isDaySelected, enter = expandVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeIn(), exit = shrinkVertically( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, ), ) + fadeOut(), ) { TimeRangeRow( startTime = dayTimes.first, endTime = dayTimes.second, onStartClick = { activeTimePicker = "day_start_$index" }, onEndClick = { activeTimePicker = "day_end_$index" }, modifier = Modifier.padding(bottom = 4.dp), ) } } } } } } } item { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.End, ) { ButtonGroup { TextButton( onClick = onDismiss, shapes = ButtonDefaults.shapes(), ) { Text(stringResource(android.R.string.cancel)) } androidx.compose.material3.Button( shapes = ButtonDefaults.shapes(), onClick = { val (finalRepeat, finalDayTimes) = when (selectedRepeat) { "weekdays_weekends" -> { val repeat = when { weekdaysEnabled && weekendsEnabled -> "weekdays_weekends" weekdaysEnabled -> "weekdays" weekendsEnabled -> "weekends" else -> "daily" } val times = buildMap { if (weekdaysEnabled) { for (d in WEEKDAY_INDICES) put(d, weekdaysStart to weekdaysEnd) } if (weekendsEnabled) { for (d in WEEKEND_INDICES) put(d, weekendsStart to weekendsEnd) } } repeat to times } else -> { selectedRepeat to dayTimesMap.toMap() } } onConfirm(finalRepeat, selectedStartTime, selectedEndTime, selectedDays, finalDayTimes) onDismiss() }, ) { Text(stringResource(android.R.string.ok)) } } } } } } @Composable private fun TimeRangeRow( startTime: String, endTime: String, onStartClick: () -> Unit, onEndClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { FilledTonalButton(onClick = onStartClick, modifier = Modifier.weight(1f)) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(stringResource(R.string.sleep_timer_start_time), style = MaterialTheme.typography.labelSmall) Text(startTime, style = MaterialTheme.typography.bodyLarge) } } FilledTonalButton(onClick = onEndClick, modifier = Modifier.weight(1f)) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(stringResource(R.string.sleep_timer_end_time), style = MaterialTheme.typography.labelSmall) Text(endTime, style = MaterialTheme.typography.bodyLarge) } } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun SleepTimerTimePickerDialog( title: String, onDismiss: () -> Unit, onConfirm: (String) -> Unit, initialTime: String = DEFAULT_START, ) { val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") val initialLocalTime = try { LocalTime.parse(initialTime, timeFormatter) } catch (e: Exception) { LocalTime.of(9, 0) } val timePickerState = rememberTimePickerState( initialHour = initialLocalTime.hour, initialMinute = initialLocalTime.minute, is24Hour = true, ) DefaultDialog( title = { Text(title) }, onDismiss = onDismiss, buttons = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } Button(onClick = { val hour = timePickerState.hour.toString().padStart(2, '0') val minute = timePickerState.minute.toString().padStart(2, '0') onConfirm("$hour:$minute") }) { Text(stringResource(android.R.string.ok)) } }, ) { TimePicker(state = timePickerState) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/SongDropdownSelect.kt ================================================ package com.metrolist.music.ui.component import androidx.compose.foundation.horizontalScroll 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.heightIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState 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.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import com.metrolist.music.R import com.metrolist.music.db.entities.SongWithStats @Composable fun SongSelectDropdown( titleT: String, songs: List, selectedSong: MutableState, ) { var expanded by remember { mutableStateOf(false) } var searchText by remember { mutableStateOf("") } var textFieldWidthPx by remember { mutableIntStateOf(0) } val density = LocalDensity.current val filteredSongs = songs.filter { song -> song.title.contains(searchText, ignoreCase = true) } val maxItemsShown = 75 val visibleSongs = filteredSongs.take(maxItemsShown) val remainingCount = filteredSongs.size - visibleSongs.size Box(modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically) { TextField( value = searchText, onValueChange = { searchText = it expanded = true selectedSong.value = null }, label = { Text(titleT) }, modifier = Modifier .weight(1f) .onGloballyPositioned { coordinates -> textFieldWidthPx = coordinates.size.width } ) Spacer(modifier = Modifier.width(8.dp)) } DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, modifier = Modifier.width(with(density) { textFieldWidthPx.toDp() }), properties = PopupProperties(focusable = false) ) { Column( modifier = Modifier .heightIn(max = 160.dp) // the scroll "box" .verticalScroll(rememberScrollState()) ) { visibleSongs.forEach { song -> val scrollState = rememberScrollState() DropdownMenuItem( onClick = { searchText = song.title selectedSong.value = song expanded = false }, text = { Row (modifier = Modifier.horizontalScroll(scrollState)) { Text( text = song.title, maxLines = 1, ) Spacer(modifier = Modifier.width(8.dp)) val displayArtists = song.artists.joinToString(", ") { it.name }.ifBlank { song.artistName } displayArtists?.let { Text( text = it, maxLines = 1, color = androidx.compose.ui.graphics.Color.Gray, overflow = TextOverflow.Ellipsis // Highly recommended for multi-artist names ) } } }, modifier = Modifier.fillMaxWidth() ) } if (remainingCount > 0) { DropdownMenuItem( onClick = { /* no-op */ }, enabled = false, text = { Text( text = stringResource( R.string.song_dropdown_more_results, remainingCount, ), ) }, modifier = Modifier.fillMaxWidth() ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/SortHeader.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.music.R import com.metrolist.music.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) }, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/SpeedDialGridItem.kt ================================================ package com.metrolist.music.ui.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape 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.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color 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.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.YTItem import com.metrolist.music.R import com.metrolist.music.constants.ThumbnailCornerRadius @Composable fun SpeedDialGridItem( item: YTItem, isPinned: Boolean, modifier: Modifier = Modifier, isActive: Boolean = false, isPlaying: Boolean = false, ) { Box( modifier = modifier .fillMaxWidth() .aspectRatio(1f) // Square aspect ratio .clip(RoundedCornerShape(ThumbnailCornerRadius)) ) { // Thumbnail ItemThumbnail( thumbnailUrl = item.thumbnail, isActive = isActive, isPlaying = isPlaying, shape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius), modifier = Modifier.fillMaxSize() ) // Gradient Overlay for Text Readability and Icon Contrast Box( modifier = Modifier .fillMaxSize() .background( Brush.verticalGradient( colors = listOf( Color.Black.copy(alpha = 0.4f), // Top scrim for icon visibility on bright covers Color.Transparent, Color.Black.copy(alpha = 0.6f), Color.Black.copy(alpha = 0.9f) ) ) ) ) // Title and Chevron Row( modifier = Modifier .align(Alignment.BottomStart) .padding(8.dp) // Reduced padding for tighter layout .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text( text = item.title, style = MaterialTheme.typography.titleSmall, // Smaller, punchier font fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) // Navigation Chevron for browsable items (Album, Playlist, Artist) if (item !is SongItem) { Icon( painter = painterResource(R.drawable.navigate_next), contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp) ) } } // Pinned Icon if (isPinned) { Icon( painter = painterResource(R.drawable.ic_push_pin), contentDescription = null, tint = Color.White, modifier = Modifier .align(Alignment.TopEnd) .padding(8.dp) .size(16.dp) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/SquigglySlider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors * * Squiggly Slider - ported from mpvEx project * https://github.com/marlboro-advance/mpvEx */ package com.metrolist.music.ui.component import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.SliderColors import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.setValue import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @Composable fun SquigglySlider( value: Float, onValueChange: (Float) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, valueRange: ClosedFloatingPointRange = 0f..1f, onValueChangeFinished: (() -> Unit)? = null, colors: SliderColors = SliderDefaults.colors(), isPlaying: Boolean = true, ) { val primaryColor = colors.activeTrackColor val inactiveColor = colors.inactiveTrackColor var isDragging by remember { mutableStateOf(false) } var dragPosition by remember { mutableFloatStateOf(value) } val currentValue = if (isDragging) dragPosition else value val duration = valueRange.endInclusive - valueRange.start val position = currentValue - valueRange.start // Animation state var phaseOffset by remember { mutableFloatStateOf(0f) } var heightFraction by remember { mutableFloatStateOf(if (isPlaying) 1f else 0f) } val scope = rememberCoroutineScope() // Wave parameters val waveLength = 80f val lineAmplitude = 6f val phaseSpeed = 24f // Faster wave movement to match old squiggly val transitionPeriods = 1.5f val minWaveEndpoint = 0f val matchedWaveEndpoint = 1f val transitionEnabled = true // Animate height fraction based on playing state and dragging state LaunchedEffect(isPlaying, isDragging) { scope.launch { val shouldFlatten = !isPlaying || isDragging val targetHeight = if (shouldFlatten) 0f else 1f val animDuration = if (shouldFlatten) 150 else 200 // Faster appear/disappear val startDelay = if (shouldFlatten) 0L else 30L delay(startDelay) val animator = Animatable(heightFraction) animator.animateTo( targetValue = targetHeight, animationSpec = tween( durationMillis = animDuration, easing = LinearEasing, ), ) { heightFraction = this.value } } } // Animate wave movement only when playing LaunchedEffect(isPlaying) { if (!isPlaying) return@LaunchedEffect var lastFrameTime = withFrameMillis { it } while (isActive) { withFrameMillis { frameTimeMillis -> val deltaTime = (frameTimeMillis - lastFrameTime) / 1000f phaseOffset += deltaTime * phaseSpeed phaseOffset %= waveLength lastFrameTime = frameTimeMillis } } } Box( modifier = modifier .fillMaxWidth() .height(48.dp) .then( if (enabled) { Modifier .pointerInput(valueRange) { detectTapGestures { offset -> val newPosition = (offset.x / size.width) * duration val mappedValue = valueRange.start + newPosition.coerceIn(0f, duration) onValueChange(mappedValue) onValueChangeFinished?.invoke() } } .pointerInput(valueRange) { detectDragGestures( onDragStart = { offset -> isDragging = true val newPosition = (offset.x / size.width) * duration dragPosition = valueRange.start + newPosition.coerceIn(0f, duration) onValueChange(dragPosition) }, onDragEnd = { isDragging = false onValueChangeFinished?.invoke() }, onDragCancel = { isDragging = false }, onDrag = { change, _ -> change.consume() val newPosition = (change.position.x / size.width) * duration dragPosition = valueRange.start + newPosition.coerceIn(0f, duration) onValueChange(dragPosition) } ) } } else { Modifier } ), contentAlignment = Alignment.Center ) { Canvas( modifier = Modifier .fillMaxWidth() .height(48.dp) ) { val strokeWidth = 5.dp.toPx() val progress = if (duration > 0f) (position / duration).coerceIn(0f, 1f) else 0f val totalWidth = size.width val totalProgressPx = totalWidth * progress val centerY = size.height / 2f // Calculate wave progress val waveProgressPx = if (!transitionEnabled || progress > matchedWaveEndpoint) { totalWidth * progress } else { val t = (progress / matchedWaveEndpoint).coerceIn(0f, 1f) totalWidth * (minWaveEndpoint + (matchedWaveEndpoint - minWaveEndpoint) * t) } // Helper function to compute amplitude fun computeAmplitude(x: Float, sign: Float): Float { return if (transitionEnabled) { val length = transitionPeriods * waveLength val coeff = ((waveProgressPx + length / 2f - x) / length).coerceIn(0f, 1f) sign * heightFraction * lineAmplitude * coeff } else { sign * heightFraction * lineAmplitude } } // Build wavy path for played portion val path = Path() val waveStart = -phaseOffset - waveLength / 2f val waveEnd = if (transitionEnabled) totalWidth else waveProgressPx path.moveTo(waveStart, centerY) var currentX = waveStart var waveSign = 1f var currentAmp = computeAmplitude(currentX, waveSign) val dist = waveLength / 2f while (currentX < waveEnd) { waveSign = -waveSign val nextX = currentX + dist val midX = currentX + dist / 2f val nextAmp = computeAmplitude(nextX, waveSign) path.cubicTo( midX, centerY + currentAmp, midX, centerY + nextAmp, nextX, centerY + nextAmp, ) currentAmp = nextAmp currentX = nextX } // Draw path up to progress position using clipping val clipTop = lineAmplitude + strokeWidth val disabledAlpha = 77f / 255f val inactiveTrackColor = primaryColor.copy(alpha = disabledAlpha) val capRadius = strokeWidth / 2f fun drawPathSegment(startX: Float, endX: Float, color: Color) { if (endX <= startX) return clipRect( left = startX, top = centerY - clipTop, right = endX, bottom = centerY + clipTop, ) { drawPath( path = path, color = color, style = Stroke(width = strokeWidth, cap = StrokeCap.Round), ) } } // Played segment drawPathSegment(0f, totalProgressPx, primaryColor) // Unplayed segment drawPathSegment(totalProgressPx, totalWidth, inactiveTrackColor) // Helper function to get wave Y position at any X fun getWaveY(x: Float): Float { val phase = (x - waveStart) / waveLength val waveCycle = phase - kotlin.math.floor(phase) val waveValue = kotlin.math.cos(waveCycle * 2f * kotlin.math.PI.toFloat()) // Calculate amplitude coefficient at this x position val ampCoeff = if (transitionEnabled) { val length = transitionPeriods * waveLength ((waveProgressPx + length / 2f - x) / length).coerceIn(0f, 1f) } else { 1f } return centerY + waveValue * lineAmplitude * heightFraction * ampCoeff } // Draw round cap at start (synced with wave) drawCircle( color = primaryColor, radius = capRadius, center = Offset(0f, getWaveY(0f)), ) // Draw round cap at end (only right half, synced with wave movement) val endWaveY = getWaveY(totalWidth) clipRect( left = totalWidth, top = centerY - clipTop, right = totalWidth + capRadius, bottom = centerY + clipTop, ) { drawCircle( color = inactiveTrackColor, radius = capRadius, center = Offset(totalWidth, endWaveY), ) } // Vertical Bar Thumb val barHalfHeight = (lineAmplitude + strokeWidth) val barWidth = 5.dp.toPx() if (barHalfHeight > 0.5f) { drawLine( color = primaryColor, start = Offset(totalProgressPx, centerY - barHalfHeight), end = Offset(totalProgressPx, centerY + barHalfHeight), strokeWidth = barWidth, cap = StrokeCap.Round, ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/TimeTransfer.kt ================================================ package com.metrolist.music.ui.component 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.material3.Button 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.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.metrolist.music.R import com.metrolist.music.db.entities.SongWithStats import com.metrolist.music.viewmodels.StatsViewModel import java.util.Locale import java.util.concurrent.TimeUnit @Composable fun TimeTransfer( onDismiss: () -> Unit, viewModel: StatsViewModel = hiltViewModel() ) { val sourceSong = remember { mutableStateOf(null) } val targetSong = remember { mutableStateOf(null) } val mostPlayedSongsStats by viewModel.mostPlayedSongsStats.collectAsState() DefaultDialog( onDismiss = onDismiss, title = { Text( text = stringResource(R.string.time_transfer_title), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) }, content = { Text( text = stringResource(R.string.time_transfer_warning), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = androidx.compose.ui.graphics.Color.Red, ) Spacer(modifier = Modifier.height(12.dp)) Column { SongSelectDropdown( titleT = stringResource(R.string.time_transfer_source_song), songs = mostPlayedSongsStats, selectedSong = sourceSong ) Spacer(modifier = Modifier.height(12.dp)) Row { Text(stringResource(R.string.time_transfer_listen_time_label)) if (sourceSong.value != null) { Text( text = formatMillis(sourceSong.value!!.timeListened), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, ) } } Spacer(modifier = Modifier.height(12.dp)) SongSelectDropdown( titleT = stringResource(R.string.time_transfer_target_song), songs = mostPlayedSongsStats, selectedSong = targetSong, ) Spacer(modifier = Modifier.height(12.dp)) Row { Text(stringResource(R.string.time_transfer_listen_time_label)) if (targetSong.value != null) { Text( text = formatMillis(targetSong.value!!.timeListened), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, ) } } Spacer(modifier = Modifier.height(12.dp)) Button( onClick = { val from = sourceSong.value?.id val to = targetSong.value?.id if (from != null && to != null && from != to) { viewModel.transferSongStats(from, to) { sourceSong.value = null targetSong.value = null onDismiss() } } }, modifier = Modifier.fillMaxWidth(), enabled = sourceSong.value != null && targetSong.value != null && sourceSong.value!!.id != targetSong.value!!.id, ) { Text( text = stringResource(R.string.time_transfer_convert), color = MaterialTheme.colorScheme.onPrimary, ) } } } ) } fun formatMillis(ms: Long?): String { if (ms == null) { return "00:00:00" } val hours = TimeUnit.MILLISECONDS.toHours(ms) val minutes = TimeUnit.MILLISECONDS.toMinutes(ms) % 60 val seconds = TimeUnit.MILLISECONDS.toSeconds(ms) % 60 return String.format(Locale.US,"%02d:%02d:%02d", hours, minutes, seconds) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/VolumeSlider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors * * Material 3 Expressive Volume Slider * Based on M3 Expressive Slider specifications (Size M): * - Track height: 40dp * - Handle height: 52dp * - Handle width: 4dp * - Track corner radius: 12dp * - Inset icon size: 24dp */ package com.metrolist.music.ui.component import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.metrolist.music.R /** * Material 3 Expressive Volume Slider dimensions (Size M) */ private object VolumeSliderDefaults { val TrackHeight: Dp = 40.dp val HandleHeight: Dp = 52.dp val HandleWidth: Dp = 4.dp val TrackCornerRadius: Dp = 12.dp val InsetIconSize: Dp = 24.dp val IconPadding: Dp = 10.dp val ThumbTrackGapSize: Dp = 6.dp val StopIndicatorRadius: Dp = 4.dp } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun VolumeSlider( value: Float, onValueChange: (Float) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, onValueChangeFinished: (() -> Unit)? = null, accentColor: Color = MaterialTheme.colorScheme.primary ) { val interactionSource = remember { MutableInteractionSource() } val volumeOffIcon = painterResource(R.drawable.volume_off) val volumeMuteIcon = painterResource(R.drawable.volume_mute) val volumeDownIcon = painterResource(R.drawable.volume_down) val volumeUpIcon = painterResource(R.drawable.volume_up) val currentIcon = when { value <= 0f -> volumeOffIcon value < 0.33f -> volumeMuteIcon value < 0.66f -> volumeDownIcon else -> volumeUpIcon } val colors = SliderDefaults.colors( thumbColor = accentColor, activeTrackColor = accentColor, activeTickColor = MaterialTheme.colorScheme.onPrimary, inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant, inactiveTickColor = MaterialTheme.colorScheme.onSurfaceVariant ) val stopIndicatorColor = MaterialTheme.colorScheme.onSurfaceVariant Slider( value = value, onValueChange = onValueChange, modifier = modifier, enabled = enabled, valueRange = 0f..1f, onValueChangeFinished = onValueChangeFinished, colors = colors, interactionSource = interactionSource, track = { sliderState -> val iconSize = DpSize(VolumeSliderDefaults.InsetIconSize, VolumeSliderDefaults.InsetIconSize) val activeIconColor = colors.activeTickColor val inactiveIconColor = colors.inactiveTickColor SliderDefaults.Track( sliderState = sliderState, modifier = Modifier .height(VolumeSliderDefaults.TrackHeight) .drawWithContent { drawContent() val yOffset = size.height / 2 - iconSize.toSize().height / 2 val fraction = value.coerceIn(0f, 1f) val thumbGapPx = VolumeSliderDefaults.ThumbTrackGapSize.toPx() val activeTrackEnd = size.width * fraction - thumbGapPx val inactiveTrackStart = activeTrackEnd + thumbGapPx * 2 val activeTrackWidth = activeTrackEnd val inactiveTrackWidth = size.width - inactiveTrackStart drawVolumeIcon( icon = currentIcon, iconSize = iconSize, iconPadding = VolumeSliderDefaults.IconPadding, yOffset = yOffset, activeTrackWidth = activeTrackWidth, inactiveTrackStart = inactiveTrackStart, inactiveTrackWidth = inactiveTrackWidth, activeIconColor = activeIconColor, inactiveIconColor = inactiveIconColor, volumeOffIcon = volumeOffIcon ) }, colors = colors, enabled = enabled, thumbTrackGapSize = VolumeSliderDefaults.ThumbTrackGapSize, trackCornerSize = VolumeSliderDefaults.TrackCornerRadius, drawStopIndicator = if (value < 0.90f) { offset -> drawCircle( color = stopIndicatorColor, radius = VolumeSliderDefaults.StopIndicatorRadius.toPx(), center = offset ) } else null ) } ) } private fun DrawScope.drawVolumeIcon( icon: Painter, iconSize: DpSize, iconPadding: Dp, yOffset: Float, activeTrackWidth: Float, inactiveTrackStart: Float, inactiveTrackWidth: Float, activeIconColor: Color, inactiveIconColor: Color, volumeOffIcon: Painter ) { val iconSizePx = iconSize.toSize() val iconPaddingPx = iconPadding.toPx() val minSpaceForIcon = iconSizePx.width + iconPaddingPx * 2 if (activeTrackWidth >= minSpaceForIcon) { translate(iconPaddingPx, yOffset) { with(icon) { draw(iconSizePx, colorFilter = ColorFilter.tint(activeIconColor)) } } } else if (inactiveTrackWidth >= minSpaceForIcon) { translate(inactiveTrackStart + iconPaddingPx, yOffset) { with(volumeOffIcon) { draw(iconSizePx, colorFilter = ColorFilter.tint(inactiveIconColor)) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/WavySlider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.SliderColors import androidx.compose.material3.SliderDefaults import androidx.compose.material3.WavyProgressIndicatorDefaults 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.geometry.Offset import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun WavySlider( value: Float, onValueChange: (Float) -> Unit, modifier: Modifier = Modifier, valueRange: ClosedFloatingPointRange = 0f..1f, onValueChangeFinished: (() -> Unit)? = null, colors: SliderColors = SliderDefaults.colors(), isPlaying: Boolean = true, enabled: Boolean = true, strokeWidth: Dp = 4.dp, thumbRadius: Dp = 8.dp, wavelength: Dp = WavyProgressIndicatorDefaults.LinearDeterminateWavelength, waveSpeed: Dp = wavelength ) { val density = LocalDensity.current val strokeWidthPx = with(density) { strokeWidth.toPx() } val thumbRadiusPx = with(density) { thumbRadius.toPx() } val stroke = remember(strokeWidthPx) { Stroke(width = strokeWidthPx, cap = StrokeCap.Round) } val normalizedValue = ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)) .coerceIn(0f, 1f) var isDragging by remember { mutableStateOf(false) } var dragValue by remember { mutableFloatStateOf(normalizedValue) } val displayValue = if (isDragging) dragValue else normalizedValue val animatedAmplitude by animateFloatAsState( targetValue = if (isPlaying) 1f else 0f, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, label = "amplitude" ) val activeColor = colors.activeTrackColor val inactiveColor = colors.inactiveTrackColor val thumbColor = colors.thumbColor // Calculate container height to accommodate thumb val containerHeight = maxOf(WavyProgressIndicatorDefaults.LinearContainerHeight, thumbRadius * 2) val baseModifier = modifier .fillMaxWidth() .height(containerHeight) val interactiveModifier = if (enabled) { baseModifier .pointerInput(valueRange) { detectTapGestures { offset -> val newValue = (offset.x / size.width).coerceIn(0f, 1f) val mappedValue = valueRange.start + newValue * (valueRange.endInclusive - valueRange.start) onValueChange(mappedValue) onValueChangeFinished?.invoke() } } .pointerInput(valueRange) { detectHorizontalDragGestures( onDragStart = { offset -> isDragging = true dragValue = (offset.x / size.width).coerceIn(0f, 1f) val mappedValue = valueRange.start + dragValue * (valueRange.endInclusive - valueRange.start) onValueChange(mappedValue) }, onDragEnd = { isDragging = false onValueChangeFinished?.invoke() }, onDragCancel = { isDragging = false }, onHorizontalDrag = { _, dragAmount -> dragValue = (dragValue + dragAmount / size.width).coerceIn(0f, 1f) val mappedValue = valueRange.start + dragValue * (valueRange.endInclusive - valueRange.start) onValueChange(mappedValue) } ) } } else { baseModifier } Box( modifier = interactiveModifier, contentAlignment = Alignment.Center ) { LinearWavyProgressIndicator( progress = { displayValue }, modifier = Modifier.fillMaxWidth(), color = activeColor, trackColor = inactiveColor, stroke = stroke, trackStroke = stroke, gapSize = thumbRadius + 4.dp, stopSize = WavyProgressIndicatorDefaults.LinearTrackStopIndicatorSize, amplitude = { progress -> if (progress > 0f) animatedAmplitude else 0f }, wavelength = wavelength, waveSpeed = waveSpeed ) // Draw circular thumb - synced with progress indicator position Canvas(modifier = Modifier.fillMaxSize()) { val thumbX = size.width * displayValue val thumbY = size.height / 2 drawCircle( color = thumbColor, radius = thumbRadiusPx, center = Offset(thumbX, thumbY) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/shimmer/ButtonPlaceholder.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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), ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/shimmer/GridItemPlaceholder.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.runtime.getValue 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.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.constants.SmallGridThumbnailHeight import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.utils.rememberEnumPreference @Composable fun GridItemPlaceHolder( modifier: Modifier = Modifier, thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius), fillMaxWidth: Boolean = false, ) { val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) val gridHeight = if (gridItemSize == GridItemSize.BIG) GridThumbnailHeight else SmallGridThumbnailHeight Column( modifier = if (fillMaxWidth) { modifier .padding(12.dp) .fillMaxWidth() } else { modifier .padding(12.dp) .width(gridHeight) }, ) { Spacer( modifier = if (fillMaxWidth) { Modifier.fillMaxWidth() } else { Modifier.height(gridHeight) }.aspectRatio(1f) .clip(thumbnailShape) .background(MaterialTheme.colorScheme.onSurface), ) Spacer(modifier = Modifier.height(6.dp)) TextPlaceholder() TextPlaceholder() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/shimmer/ListItemPlaceholder.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.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() } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/shimmer/ShimmerHost.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, showGradient: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { val baseModifier = modifier .shimmer() .graphicsLayer(alpha = 0.99f) Column( horizontalAlignment = horizontalAlignment, verticalArrangement = verticalArrangement, modifier = if (showGradient) { baseModifier.drawWithContent { drawContent() drawRect( brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), blendMode = BlendMode.DstIn, ) } } else { baseModifier }, 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), ), ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/component/shimmer/TextPlaceholder.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.component.shimmer import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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, shape: CornerBasedShape = RoundedCornerShape(0.dp) ) { Box( modifier = modifier .padding(vertical = 4.dp) .height(height) .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) .clip(shape) .background(MaterialTheme.colorScheme.onSurface) ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/AddToPlaylistDialog.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row 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.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.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.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.metrolist.innertube.YouTube import com.metrolist.innertube.utils.parseCookieString import com.metrolist.music.LocalDatabase import com.metrolist.music.R import com.metrolist.music.constants.AddToPlaylistSortDescendingKey import com.metrolist.music.constants.AddToPlaylistSortTypeKey import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.constants.PlaylistSortType import com.metrolist.music.db.entities.Playlist import com.metrolist.music.ui.component.CreatePlaylistDialog import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.ListItem import com.metrolist.music.ui.component.PlaylistListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.PlaylistsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import androidx.compose.foundation.background import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color import kotlinx.coroutines.withContext import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconToggleButton import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.material3.FilterChip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.material3.FilterChipDefaults @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, viewModel: PlaylistsViewModel = hiltViewModel() ) { val database = LocalDatabase.current val coroutineScope = rememberCoroutineScope() val (sortType, onSortTypeChange) = rememberEnumPreference( AddToPlaylistSortTypeKey, PlaylistSortType.NAME ) val (sortDescending, onSortDescendingChange) = rememberPreference( AddToPlaylistSortDescendingKey, false ) val playlists by viewModel.allPlaylists.collectAsState() 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()) } var playlistsContainingSong by remember { mutableStateOf>(emptySet()) } LaunchedEffect(isVisible) { if (!isVisible) { songIds = null playlistsContainingSong = emptySet() return@LaunchedEffect } if (playlists.isNotEmpty() && songIds == null) { withContext(Dispatchers.IO) { val ids = onGetSong(playlists.first()) songIds = ids } } } LaunchedEffect(songIds, playlists) { val ids = songIds ?: return@LaunchedEffect withContext(Dispatchers.IO) { playlistsContainingSong = playlists .filter { playlist -> database.playlistDuplicates(playlist.id, ids).isNotEmpty() } .map { it.id } .toSet() } } if (isVisible) { ListDialog( onDismiss = onDismiss, ) { item { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState( targetValue = if (isPressed) 0.7f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium ), label = "buttonScale" ) FilledTonalButton( onClick = { showCreatePlaylistDialog = true}, shape = RoundedCornerShape(50), interactionSource = interactionSource, colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) .graphicsLayer { scaleX = scale; scaleY = scale } ) { Icon( painter = painterResource(R.drawable.add), contentDescription = null, modifier = Modifier .padding(end = 8.dp) .size(20.dp) ) Text( text = stringResource(R.string.create_playlist), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) } } if (playlists.isNotEmpty()) { item { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp), ) { FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f) ) { PlaylistSortType.entries.forEach { type -> val selected = sortType == type FilterChip( selected = selected, onClick = { onSortTypeChange(type) }, shape = RoundedCornerShape(50), border = FilterChipDefaults.filterChipBorder( enabled = true, selected = selected, borderWidth = 0.dp, selectedBorderWidth = 0.dp, ), colors = FilterChipDefaults.filterChipColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, labelColor = MaterialTheme.colorScheme.onSurfaceVariant, selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, ), label = { Text( text = stringResource(when (type) { 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 }), fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, ) } ) } } val arrowBg by animateColorAsState( targetValue = if (sortDescending) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceVariant, animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "arrowBg" ) val arrowFg by animateColorAsState( targetValue = if (sortDescending) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "arrowFg" ) IconToggleButton( checked = sortDescending, onCheckedChange = { onSortDescendingChange(it) }, modifier = Modifier .clip(RoundedCornerShape(50)) .background(arrowBg) .size(36.dp) ) { Icon( painter = painterResource( if (sortDescending) R.drawable.arrow_downward else R.drawable.arrow_upward ), contentDescription = stringResource( if (sortDescending) R.string.sort_descending else R.string.sort_ascending ), tint = arrowFg, modifier = Modifier.size(18.dp) ) } } } } items(playlists) { playlist -> val containsSong = playlist.id in playlistsContainingSong val rowBg by animateColorAsState( targetValue = if (containsSong) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) else Color.Transparent, animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "playlistBg" ) PlaylistListItem( playlist = playlist, modifier = Modifier .padding(horizontal = 8.dp, vertical = 2.dp) .clip(RoundedCornerShape(16.dp)) .background(rowBg) .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) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/AddToPlaylistDialogOnline.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row 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.collectAsState 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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.SongItem import com.metrolist.music.LocalDatabase import com.metrolist.music.R import com.metrolist.music.constants.AddToPlaylistSortDescendingKey import com.metrolist.music.constants.AddToPlaylistSortTypeKey import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.constants.PlaylistSortType import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.Song import com.metrolist.music.models.ItemsPage import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.ui.component.CreatePlaylistDialog import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.ListItem import com.metrolist.music.ui.component.PlaylistListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.utils.reportException import com.metrolist.music.viewmodels.PlaylistsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import timber.log.Timber import java.net.URLDecoder import java.nio.charset.StandardCharsets import java.util.concurrent.atomic.AtomicInteger import androidx.compose.runtime.LaunchedEffect import androidx.compose.animation.animateColorAsState 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.layout.Arrangement import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconToggleButton import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults @Composable fun AddToPlaylistDialogOnline( isVisible: Boolean, allowSyncing: Boolean = true, initialTextFieldValue: String? = null, songs: SnapshotStateList, onDismiss: () -> Unit, onProgressStart: (Boolean) -> Unit, onPercentageChange: (Int) -> Unit, onSongChange: (String) -> Unit = {}, viewModel: PlaylistsViewModel = hiltViewModel() ) { val database = LocalDatabase.current val coroutineScope = rememberCoroutineScope() val viewStateMap = remember { mutableStateMapOf() } val (sortType, onSortTypeChange) = rememberEnumPreference( AddToPlaylistSortTypeKey, PlaylistSortType.NAME ) val (sortDescending, onSortDescendingChange) = rememberPreference( AddToPlaylistSortDescendingKey, false ) val playlists by viewModel.allPlaylists.collectAsState() var showCreatePlaylistDialog by rememberSaveable { mutableStateOf(false) } var showDuplicateDialog by remember { mutableStateOf(false) } var selectedPlaylist by remember { mutableStateOf(null) } val songIds by remember { mutableStateOf?>(null) } val duplicates by remember { mutableStateOf(emptyList()) } var playlistsContainingSong by remember { mutableStateOf>(emptySet()) } LaunchedEffect(isVisible, playlists) { if (!isVisible) { playlistsContainingSong = emptySet() return@LaunchedEffect } playlistsContainingSong = emptySet() if (playlists.isNotEmpty()) { withContext(Dispatchers.IO) { val ids = songs.map { it.id } playlistsContainingSong = playlists .filter { playlist -> database.playlistDuplicates(playlist.id, ids).isNotEmpty() } .map { it.id } .toSet() } } } if (isVisible) { ListDialog( onDismiss = onDismiss ) { item { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState( targetValue = if (isPressed) 0.94f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium ), label = "buttonScale" ) FilledTonalButton( onClick = { showCreatePlaylistDialog = true }, shape = RoundedCornerShape(50), interactionSource = interactionSource, colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) .graphicsLayer { scaleX = scale; scaleY = scale } ) { Icon( painter = painterResource(R.drawable.add), contentDescription = null, modifier = Modifier.padding(end = 8.dp).size(20.dp) ) Text( text = stringResource(R.string.create_playlist), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) } } if (playlists.isNotEmpty()) { item { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp), ) { FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f) ) { PlaylistSortType.entries.forEach { type -> val selected = sortType == type FilterChip( selected = selected, onClick = { onSortTypeChange(type) }, shape = RoundedCornerShape(50), border = FilterChipDefaults.filterChipBorder( enabled = true, selected = selected, borderWidth = 0.dp, selectedBorderWidth = 0.dp, ), colors = FilterChipDefaults.filterChipColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, labelColor = MaterialTheme.colorScheme.onSurfaceVariant, selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, ), label = { Text( text = stringResource(when (type) { 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 }), fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, ) } ) } } val arrowBg by animateColorAsState( targetValue = if (sortDescending) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceVariant, animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "arrowBg" ) val arrowFg by animateColorAsState( targetValue = if (sortDescending) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "arrowFg" ) IconToggleButton( checked = sortDescending, onCheckedChange = { onSortDescendingChange(it) }, modifier = Modifier .clip(RoundedCornerShape(50)) .background(arrowBg) .size(36.dp) ) { Icon( painter = painterResource( if (sortDescending) R.drawable.arrow_downward else R.drawable.arrow_upward ), contentDescription = stringResource( if (sortDescending) R.string.sort_descending else R.string.sort_ascending ), tint = arrowFg, modifier = Modifier.size(18.dp) ) } } } } items(playlists) { playlist -> val containsSong = playlist.id in playlistsContainingSong val rowBg by animateColorAsState( targetValue = if (containsSong) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f) else Color.Transparent, animationSpec = spring(stiffness = Spring.StiffnessMediumLow), label = "playlistBg" ) PlaylistListItem( playlist = playlist, modifier = Modifier .padding(horizontal = 8.dp, vertical = 2.dp) .clip(RoundedCornerShape(16.dp)) .background(rowBg) .clickable { selectedPlaylist = playlist coroutineScope.launch(Dispatchers.IO) { onDismiss() val songsTot = songs.count() if (songsTot == 0) return@launch val songsIdx = AtomicInteger(0) val semaphore = kotlinx.coroutines.sync.Semaphore(15) onProgressStart(true) try { val jobs = songs.reversed().map { song -> coroutineScope.launch { semaphore.withPermit { try { var allArtists = "" song.artists.forEach { artist -> allArtists += " ${URLDecoder.decode(artist.name, StandardCharsets.UTF_8.toString())}" } val query = "${song.title} - $allArtists" YouTube.search(query, YouTube.SearchFilter.FILTER_SONG) .onSuccess { result -> val items = result.items.distinctBy { it.id } if (items.isNotEmpty()) { val firstSong = items.firstOrNull() as? SongItem if (firstSong != null) { val firstSongMedia = firstSong.toMediaMetadata() val ids = listOf(firstSong.id) withContext(Dispatchers.IO) { try { database.insert(firstSongMedia) } catch (e: Exception) { Timber.tag("Exception").e(e.toString()) } database.addSongToPlaylist(playlist, ids) } } } } .onFailure { reportException(it) } } catch (e: Exception) { Timber.tag("ERROR").v(e.toString()) } finally { val completed = songsIdx.incrementAndGet() onSongChange(song.title) onPercentageChange(((completed.toDouble() / songsTot) * 100).toInt()) } } } } jobs.forEach { it.join() } } finally { withContext(Dispatchers.Main) { onProgressStart(false) } } } } ) } item { ListItem( modifier = Modifier.clickable { coroutineScope.launch(Dispatchers.IO) { onDismiss() val songsTot = songs.count() if (songsTot == 0) return@launch val songsIdx = AtomicInteger(0) val semaphore = kotlinx.coroutines.sync.Semaphore(15) onProgressStart(true) try { val jobs = songs.reversed().map { song -> coroutineScope.launch { semaphore.withPermit { try { var allArtists = "" song.artists.forEach { artist -> allArtists += " ${URLDecoder.decode(artist.name, StandardCharsets.UTF_8.toString())}" } val query = "${song.title} - $allArtists" YouTube.search(query, YouTube.SearchFilter.FILTER_SONG) .onSuccess { result -> val items = result.items.distinctBy { it.id } if (items.isNotEmpty()) { val firstSong = items.firstOrNull() as? SongItem if (firstSong != null) { 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").e(e.toString()) } } } } } .onFailure { reportException(it) } } catch (e: Exception) { Timber.tag("ERROR").v(e.toString()) } finally { val completed = songsIdx.incrementAndGet() onSongChange(song.title) onPercentageChange(((completed.toDouble() / songsTot) * 100).toInt()) } } } } jobs.forEach { it.join() } } finally { withContext(Dispatchers.Main) { onProgressStart(false) } } } }, title = stringResource(R.string.liked_songs), thumbnailContent = { Image( painter = painterResource(id = R.drawable.favorite), contentDescription = null, modifier = Modifier.size(40.dp), colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground) ) }, 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) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/AlbumMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration import android.widget.Toast 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.Spacer 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.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator 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.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.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration 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 coil3.compose.AsyncImage import com.metrolist.innertube.YouTube import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Song import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.AlbumListItem import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.ListItem import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.menu.ExportDialog import com.metrolist.music.utils.PlaylistExporter import com.metrolist.music.utils.getExportFileUri import com.metrolist.music.utils.saveToPublicDocuments 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 listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost 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 { mutableIntStateOf(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 = "", ) val isPinned by database.speedDialDao.isPinned(album.id).collectAsState(initial = false) 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.distinctBy { it.id }, 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() Spacer(modifier = Modifier.height(12.dp)) val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = listOfNotNull( if (!isGuest) { NewAction( icon = { Icon( painter = painterResource(R.drawable.play), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.play), onClick = { onDismiss() if (songs.isNotEmpty()) { playerConnection.playQueue( ListQueue( title = album.album.title, items = songs.map(Song::toMediaItem), ), ) } }, ) NewAction( icon = { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.shuffle), onClick = { onDismiss() if (songs.isNotEmpty()) { album.album.playlistId?.let { playlistId -> playerConnection.service.getAutomix(playlistId) } playerConnection.playQueue( ListQueue( title = album.album.title, items = songs.shuffled().map(Song::toMediaItem), ), ) } }, ) } else { null }, NewAction( icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.share), onClick = { onDismiss() val intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/playlist?list=${album.album.playlistId}") } context.startActivity(Intent.createChooser(intent, null)) }, ), ), modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), columns = if (isGuest) 1 else 3, ) } item { Material3MenuGroup( items = listOfNotNull( if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.play_next)) }, description = { Text(text = stringResource(R.string.play_next_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_play), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.playNext(songs.map { it.toMediaItem() }) }, ) } else { null }, if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_queue)) }, description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.addToQueue(songs.map { it.toMediaItem() }) }, ) } else { null }, Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_playlist)) }, description = { Text(text = stringResource(R.string.add_to_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, ) }, onClick = { showChoosePlaylistDialog = true }, ), Material3MenuItemData( title = { Text( text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial", ) }, icon = { Icon( painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { if (isPinned) { database.speedDialDao.delete(album.id) } else { database.speedDialDao.insert( SpeedDialItem( id = album.id, secondaryId = album.album.playlistId, title = album.album.title, subtitle = album.artists.joinToString(", ") { it.name }, thumbnailUrl = album.album.thumbnailUrl, type = "ALBUM", explicit = album.album.explicit, ), ) } } onDismiss() }, ), ), ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = listOf( when (downloadState) { STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download), ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, ) }, onClick = { songs.forEach { song -> DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.id, false, ) } }, ) } STATE_QUEUED, STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { songs.forEach { song -> DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.id, false, ) } }, ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, description = { Text(text = stringResource(R.string.download_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { 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, ) } }, ) } }, ), ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { // Export album as a playlist (CSV/M3U) var showExportDialog by remember { mutableStateOf(false) } Material3MenuGroup( items = listOf( Material3MenuItemData( title = { Text(text = stringResource(R.string.export_playlist)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, onClick = { showExportDialog = true }, ), ), ) val exportPlaylistStr = stringResource(R.string.export_playlist) if (showExportDialog) { ExportDialog( onDismiss = { showExportDialog = false }, onShare = { format -> val playlistSongs = songs.map { s -> com.metrolist.music.db.entities.PlaylistSong( map = com.metrolist.music.db.entities.PlaylistSongMap( songId = s.id, playlistId = album.id, position = 0, ), song = s, ) } val result = when (format) { "csv" -> PlaylistExporter.exportPlaylistAsCSV(context, album.album.title, playlistSongs) "m3u" -> PlaylistExporter.exportPlaylistAsM3U(context, album.album.title, playlistSongs) else -> Result.failure(IllegalArgumentException("Unknown format")) } result .onSuccess { file -> val uri = getExportFileUri(context, file) val mimeType = if (format == "csv") "text/csv" else "audio/x-mpegurl" val shareIntent = Intent(Intent.ACTION_SEND).apply { type = mimeType putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(shareIntent, exportPlaylistStr)) }.onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } showExportDialog = false }, onSave = { format -> val playlistSongs = songs.map { s -> com.metrolist.music.db.entities.PlaylistSong( map = com.metrolist.music.db.entities.PlaylistSongMap( songId = s.id, playlistId = album.id, position = 0, ), song = s, ) } val export = when (format) { "csv" -> PlaylistExporter.exportPlaylistAsCSV(context, album.album.title, playlistSongs) "m3u" -> PlaylistExporter.exportPlaylistAsM3U(context, album.album.title, playlistSongs) else -> Result.failure(IllegalArgumentException("Unknown format")) } export .onSuccess { file -> val mimeType = if (format == "csv") "text/csv" else "audio/x-mpegurl" val save = saveToPublicDocuments(context, file, mimeType) save .onSuccess { Toast.makeText(context, R.string.export_success, Toast.LENGTH_SHORT).show() } .onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } }.onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } showExportDialog = false }, ) } } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = listOf( Material3MenuItemData( title = { Text(text = stringResource(R.string.view_artist)) }, description = { Text(text = album.artists.joinToString { it.name }) }, icon = { Icon( painter = painterResource(R.drawable.artist), contentDescription = null, ) }, onClick = { if (album.artists.size == 1) { navController.navigate("artist/${album.artists[0].id}") onDismiss() } else { showSelectArtistDialog = true } }, ), Material3MenuItemData( title = { Text(text = stringResource(R.string.refetch)) }, description = { Text(text = stringResource(R.string.refetch_desc)) }, icon = { Icon( painter = painterResource(R.drawable.sync), contentDescription = null, modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation), ) }, onClick = { refetchIconDegree -= 360 scope.launch(Dispatchers.IO) { YouTube.album(album.id).onSuccess { database.transaction { update(album.album, it, album.artists) } } } }, ), ), ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/ArtistMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.content.Intent import android.content.res.Configuration import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer 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.LazyColumn import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon 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.Modifier import androidx.compose.ui.platform.LocalConfiguration 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 com.metrolist.music.LocalDatabase import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ArtistSongSortType import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.db.entities.Artist import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.ArtistListItem import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid 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 listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val artistState = database.artist(originalArtist.id).collectAsState(initial = originalArtist) val artist = artistState.value ?: originalArtist val isPinned by database.speedDialDao.isPinned(artist.id).collectAsState(initial = false) ArtistListItem( artist = artist, badges = {}, trailingContent = {}, ) HorizontalDivider() Spacer(modifier = Modifier.height(12.dp)) val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = buildList { if (!isGuest) { if (artist.songCount > 0) { add( NewAction( icon = { Icon( painter = painterResource(R.drawable.play), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.play), onClick = { 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() } ) ) add( NewAction( icon = { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.shuffle), onClick = { 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() } ) ) } } add( NewAction( icon = { Icon( painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = if (isPinned) "Unpin" else "Pin", onClick = { coroutineScope.launch(Dispatchers.IO) { if (isPinned) { database.speedDialDao.delete(artist.id) } else { database.speedDialDao.insert( SpeedDialItem( id = artist.id, title = artist.artist.name, subtitle = null, thumbnailUrl = artist.artist.thumbnailUrl, type = "ARTIST" ) ) } } onDismiss() } ) ) if (artist.artist.isYouTubeArtist) { add( NewAction( icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.share), onClick = { 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)) } ) ) } }, modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), columns = if (isGuest) 1 else 3 ) } item { Material3MenuGroup( items = listOf( Material3MenuItemData( title = { Text(text = if (artist.artist.bookmarkedAt != null) stringResource(R.string.subscribed) else stringResource(R.string.subscribe)) }, icon = { Icon( painter = painterResource(if (artist.artist.bookmarkedAt != null) R.drawable.subscribed else R.drawable.subscribe), contentDescription = null, ) }, onClick = { database.transaction { update(artist.artist.toggleLike()) } } ) ) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/CsvColumnMappingDialog.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll 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.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.Checkbox import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.res.stringResource import androidx.compose.ui.text.font.FontFamily 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.metrolist.music.R import com.metrolist.music.viewmodels.ConvertedSongLog import com.metrolist.music.viewmodels.CsvImportState @Composable fun CsvColumnMappingDialog( isVisible: Boolean, csvState: CsvImportState, onDismiss: () -> Unit, onConfirm: (CsvImportState) -> Unit, ) { if (!isVisible) return var artistColumnIndex by remember { mutableIntStateOf(csvState.artistColumnIndex) } var titleColumnIndex by remember { mutableIntStateOf(csvState.titleColumnIndex) } var urlColumnIndex by remember { mutableIntStateOf(csvState.urlColumnIndex) } var hasHeader by remember { mutableStateOf(csvState.hasHeader) } Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { Column( modifier = Modifier .fillMaxWidth(0.95f) .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.surface) .padding(24.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( text = stringResource(R.string.map_csv_columns), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) // Preview rows if (csvState.previewRows.isNotEmpty()) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = "Preview", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { csvState.previewRows.take(5).forEachIndexed { rowIndex, row -> Column( modifier = Modifier, verticalArrangement = Arrangement.spacedBy(4.dp), ) { row.forEachIndexed { colIndex, cell -> Box( modifier = Modifier .width(120.dp) .clip(RoundedCornerShape(4.dp)) .background( when { rowIndex == 0 && hasHeader -> MaterialTheme.colorScheme.primaryContainer colIndex == artistColumnIndex -> MaterialTheme.colorScheme.tertiaryContainer colIndex == titleColumnIndex -> MaterialTheme.colorScheme.secondaryContainer colIndex == urlColumnIndex && urlColumnIndex >= 0 -> MaterialTheme.colorScheme.tertiaryContainer else -> MaterialTheme.colorScheme.background }, ).padding(6.dp), ) { Text( text = cell.take(18), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, fontFamily = FontFamily.Monospace, ) } } } } } } } // Header checkbox Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Checkbox( checked = hasHeader, onCheckedChange = { hasHeader = it }, ) Text( text = stringResource(R.string.first_row_is_header), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) } // Column selectors ColumnSelector( label = stringResource(R.string.artist_name_column), selectedIndex = artistColumnIndex, maxColumns = csvState.previewRows.firstOrNull()?.size ?: 0, onSelected = { artistColumnIndex = it }, ) ColumnSelector( label = stringResource(R.string.song_title_column), selectedIndex = titleColumnIndex, maxColumns = csvState.previewRows.firstOrNull()?.size ?: 0, onSelected = { titleColumnIndex = it }, ) ColumnSelector( label = stringResource(R.string.youtube_url_column), selectedIndex = urlColumnIndex, maxColumns = csvState.previewRows.firstOrNull()?.size ?: 0, allowNone = true, onSelected = { urlColumnIndex = it }, ) // Buttons Row( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), ) { OutlinedButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } Button( onClick = { onConfirm( CsvImportState( previewRows = csvState.previewRows, artistColumnIndex = artistColumnIndex, titleColumnIndex = titleColumnIndex, urlColumnIndex = urlColumnIndex, hasHeader = hasHeader, ), ) }, ) { Text(stringResource(R.string.continue_action)) } } } } } @Composable private fun ColumnSelector( label: String, selectedIndex: Int, maxColumns: Int, allowNone: Boolean = false, onSelected: (Int) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface, ) Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { if (allowNone) { if (selectedIndex == -1) { Button( onClick = { onSelected(-1) }, modifier = Modifier.height(36.dp), ) { Text(stringResource(R.string.none), style = MaterialTheme.typography.labelSmall) } } else { OutlinedButton( onClick = { onSelected(-1) }, modifier = Modifier.height(36.dp), ) { Text(stringResource(R.string.none), style = MaterialTheme.typography.labelSmall) } } } repeat(maxColumns) { index -> if (selectedIndex == index) { Button( onClick = { onSelected(index) }, modifier = Modifier.height(36.dp), ) { Text( stringResource(R.string.column_label, index + 1), style = MaterialTheme.typography.labelSmall, ) } } else { OutlinedButton( onClick = { onSelected(index) }, modifier = Modifier.height(36.dp), ) { Text( stringResource(R.string.column_label, index + 1), style = MaterialTheme.typography.labelSmall, ) } } } } } } @Composable fun CsvImportProgressDialog( isVisible: Boolean, progress: Int, recentLogs: List, onDismiss: () -> Unit, ) { if (!isVisible) return Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnBackPress = false, dismissOnClickOutside = false), ) { Column( modifier = Modifier .fillMaxWidth(0.85f) .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.surface) .padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( text = stringResource(R.string.importing_csv), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) LinearProgressIndicator( progress = { progress / 100f }, modifier = Modifier .fillMaxWidth() .height(8.dp) .clip(RoundedCornerShape(4.dp)), ) Text( text = stringResource(R.string.percentage_format, progress), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (recentLogs.isNotEmpty()) { Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = stringResource(R.string.recently_converted), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) recentLogs.forEach { log -> Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(4.dp)) .background(MaterialTheme.colorScheme.background) .padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = log.title, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = log.artists, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/CustomThumbnailMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import androidx.compose.foundation.clickable 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.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.metrolist.music.R @OptIn(ExperimentalMaterial3Api::class) @Composable fun CustomThumbnailMenu( onEdit: () -> Unit, onRemove: () -> Unit, onDismiss: () -> Unit, ) { 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.choose_from_library)) }, leadingContent = { Icon( painter = painterResource(R.drawable.insert_photo), contentDescription = null, ) }, modifier = Modifier.clickable { onEdit() onDismiss() } ) } item { ListItem( headlineContent = { Text(text = stringResource(R.string.remove_custom_image)) }, leadingContent = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, modifier = Modifier.clickable { onRemove() onDismiss() } ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/ImportPlaylistDialog.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.music.LocalDatabase import com.metrolist.music.R import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.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() } } ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/LoadingScreen.kt ================================================ @file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import androidx.compose.foundation.background 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.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.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 com.metrolist.music.R @Composable fun LoadingScreen( isVisible: Boolean, value: Int, songTitle: String? = null, onCancel: (() -> Unit)? = null ) { if (!isVisible) return Dialog( onDismissRequest = { }, properties = DialogProperties( usePlatformDefaultWidth = false, dismissOnBackPress = false, dismissOnClickOutside = false ), ) { Column( modifier = Modifier .fillMaxWidth(0.9f) .clip(RoundedCornerShape(28.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, tint = MaterialTheme.colorScheme.secondary, modifier = Modifier.size(32.dp) ) Text( text = stringResource(R.string.importing_playlist), style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) if (songTitle != null && songTitle.isNotBlank()) { Text( text = songTitle, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis ) } else { Spacer(modifier = Modifier.height(24.dp)) } Spacer(modifier = Modifier.height(8.dp)) LinearWavyProgressIndicator( progress = { value / 100f }, modifier = Modifier .fillMaxWidth() .height(8.dp), ) Text( text = stringResource(R.string.progress_percent, value.toString()), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (onCancel != null) { Spacer(modifier = Modifier.height(8.dp)) TextButton( onClick = onCancel, modifier = Modifier.align(Alignment.End) ) { Text(stringResource(R.string.cancel)) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/LyricsMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.app.SearchManager import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.res.Configuration import android.widget.Toast 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.LazyColumn 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.Switch 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration 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.lifecycle.viewmodel.compose.hiltViewModel import com.metrolist.music.LocalDatabase import com.metrolist.music.R import com.metrolist.music.constants.AiProviderKey import com.metrolist.music.constants.DeeplApiKey import com.metrolist.music.constants.DeeplFormalityKey import com.metrolist.music.constants.OpenRouterApiKey import com.metrolist.music.constants.OpenRouterBaseUrlKey import com.metrolist.music.constants.OpenRouterModelKey import com.metrolist.music.constants.TranslateLanguageKey import com.metrolist.music.constants.TranslateModeKey import com.metrolist.music.db.entities.LyricsEntity import com.metrolist.music.db.entities.SongEntity import com.metrolist.music.lyrics.LyricsTranslationHelper import com.metrolist.music.lyrics.LyricsUtils import com.metrolist.music.models.MediaMetadata import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.TextFieldDialog import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.LyricsMenuViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun LyricsMenu( lyricsProvider: () -> LyricsEntity?, songProvider: () -> SongEntity?, mediaMetadataProvider: () -> MediaMetadata, onDismiss: () -> Unit, onShowOffsetDialog: () -> Unit = {}, viewModel: LyricsMenuViewModel = hiltViewModel(), ) { val context = LocalContext.current val database = LocalDatabase.current val openRouterApiKey by rememberPreference(OpenRouterApiKey, "") val deeplApiKey by rememberPreference(DeeplApiKey, "") val aiProvider by rememberPreference(AiProviderKey, "OpenRouter") val translateLanguage by rememberPreference(TranslateLanguageKey, "en") val translateMode by rememberPreference(TranslateModeKey, "Literal") val openRouterBaseUrl by rememberPreference(OpenRouterBaseUrlKey, "https://openrouter.ai/api/v1/chat/completions") val openRouterModel by rememberPreference(OpenRouterModelKey, "google/gemini-2.5-flash-lite") val deeplFormality by rememberPreference(DeeplFormalityKey, "default") val hasApiKey = if (aiProvider == "DeepL") deeplApiKey.isNotBlank() else openRouterApiKey.isNotBlank() // Observe the authoritative translation-active state from the singleton; this persists // correctly across menu open/close cycles and avoids the lyricsProvider() race condition. val hasTranslations by LyricsTranslationHelper.hasActiveTranslations.collectAsState() 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, provider = lyricsProvider()?.provider ?: "Manual", ), ) } }, ) } 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 }, ), ) } val isNetworkAvailable by viewModel.isNetworkAvailable.collectAsState() val errorNoInternetStr = stringResource(R.string.error_no_internet) 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 = { // Try search regardless of network status indicator // as it might be a false negative viewModel.search( searchMediaMetadata.id, titleField.text, artistField.text, searchMediaMetadata.duration, searchMediaMetadata.album?.title, ) showSearchResultDialog = true // Show warning only if network is definitely unavailable if (!isNetworkAvailable) { Toast.makeText(context, errorNoInternetStr, Toast.LENGTH_SHORT).show() } }, ) { 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 { mutableIntStateOf(-1) } ListDialog( onDismiss = { showSearchResultDialog = false }, ) { itemsIndexed(results) { index, result -> Row( modifier = Modifier .fillMaxWidth() .clickable { onDismiss() viewModel.cancelSearch() database.query { upsert( LyricsEntity( id = searchMediaMetadata.id, lyrics = result.lyrics, provider = result.providerName, ), ) } }.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 = stringResource(R.string.lyrics_not_found), textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth(), ) } } } } var showRomanizationDialog by rememberSaveable { mutableStateOf(false) } var showRomanization by rememberSaveable { mutableStateOf(false) } var isChecked by remember { mutableStateOf(songProvider()?.romanizeLyrics ?: true) } var lyricsOffset by rememberSaveable { mutableIntStateOf(songProvider()?.lyricsOffset ?: 0) } // Sync isChecked with song changes LaunchedEffect(songProvider()) { isChecked = songProvider()?.romanizeLyrics ?: true } LaunchedEffect(songProvider()) { lyricsOffset = songProvider()?.lyricsOffset ?: 0 } val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = listOf( NewAction( icon = { Icon( painter = painterResource(R.drawable.edit), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.edit), onClick = { showEditDialog = true }, ), NewAction( icon = { Icon( painter = painterResource(R.drawable.cached), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.refetch), onClick = { onDismiss() viewModel.refetchLyrics(mediaMetadataProvider(), lyricsProvider()) }, ), NewAction( icon = { Icon( painter = painterResource(R.drawable.search), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.search), onClick = { showSearchDialog = true }, ), NewAction( icon = { Icon( painter = painterResource(R.drawable.content_copy), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.copy), onClick = { lyricsProvider()?.lyrics?.let { lyrics -> val plainLyrics = if (lyrics.startsWith("[")) { LyricsUtils.parseLyrics(lyrics) .joinToString("\n") { it.text } } else { lyrics } val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("Lyrics", plainLyrics) clipboard.setPrimaryClip(clip) Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() } }, ), ), modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), columns = 4, ) } item { Material3MenuGroup( items = buildList { // Add translation toggle option if API key is configured if (hasApiKey) { add( Material3MenuItemData( title = { Text(stringResource(R.string.ai_lyrics_translation)) }, icon = { Icon( painter = painterResource(R.drawable.translate), contentDescription = null, ) }, onClick = { if (hasTranslations) { // Remove translations lyricsProvider()?.let { lyrics -> val clearedLyrics = LyricsTranslationHelper.clearTranslations(lyrics) database.query { upsert(clearedLyrics) } // Resets hasActiveTranslations and clears in-memory translations LyricsTranslationHelper.triggerClearTranslations() } } else { // Trigger translation LyricsTranslationHelper.triggerManualTranslation() } }, trailingContent = { Switch( checked = hasTranslations, onCheckedChange = { newCheckedState -> if (newCheckedState) { // Enable translations – hasActiveTranslations updates when done LyricsTranslationHelper.triggerManualTranslation() } else { // Disable translations – triggerClearTranslations resets hasActiveTranslations lyricsProvider()?.let { lyrics -> val clearedLyrics = LyricsTranslationHelper.clearTranslations(lyrics) database.query { upsert(clearedLyrics) } LyricsTranslationHelper.triggerClearTranslations() } } }, ) }, ), ) } add( Material3MenuItemData( title = { Text(stringResource(R.string.lyrics_offset)) }, icon = { Icon( painter = painterResource(R.drawable.fast_forward), contentDescription = null, ) }, onClick = { onDismiss() onShowOffsetDialog() }, trailingContent = { Text( text = "${if (lyricsOffset >= 0) "+" else ""}${lyricsOffset}ms", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, ), ) add( Material3MenuItemData( title = { Text(text = stringResource(R.string.romanize_current_track)) }, icon = { Icon( painter = painterResource(R.drawable.language_korean_latin), contentDescription = null, ) }, onClick = { isChecked = !isChecked songProvider()?.let { song -> database.query { upsert(song.copy(romanizeLyrics = isChecked)) } } }, trailingContent = { Switch( checked = isChecked, onCheckedChange = { newCheckedState -> isChecked = newCheckedState songProvider()?.let { song -> database.query { upsert(song.copy(romanizeLyrics = newCheckedState)) } } }, ) }, ), ) }, ) } } /* if (showRomanizationDialog) { var isChecked by remember { mutableStateOf(songProvider()?.romanizeLyrics ?: true) } // Sync with song changes LaunchedEffect(songProvider()) { isChecked = songProvider()?.romanizeLyrics ?: true } DefaultDialog( onDismiss = { showRomanizationDialog = false }, title = { Text(stringResource(R.string.romanization)) } ) { Row( modifier = Modifier .fillMaxWidth() .clickable { // Toggle isChecked when the row is clicked isChecked = !isChecked songProvider()?.let { song -> database.query { upsert(song.copy(romanizeLyrics = isChecked)) } } } .padding(vertical = 8.dp, horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(R.string.romanize_current_track), modifier = Modifier.weight(1f) ) Switch( checked = isChecked, onCheckedChange = { newCheckedState -> isChecked = newCheckedState songProvider()?.let { song -> database.query { upsert(song.copy(romanizeLyrics = newCheckedState)) } } } ) } } } */ } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/PlayerMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.content.Context import android.content.res.Configuration import android.widget.Toast import androidx.annotation.DrawableRes 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.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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults 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.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.platform.LocalConfiguration 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.Download import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadService import androidx.navigation.NavController import com.metrolist.innertube.YouTube import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.listentogether.ConnectionState import com.metrolist.music.listentogether.ListenTogetherEvent import com.metrolist.music.models.MediaMetadata import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.ui.component.BottomSheetState import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.VolumeSlider import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.math.log2 import kotlin.math.pow import kotlin.math.round @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() // Cast state for volume control - safely access castConnectionHandler to prevent crashes val castHandler = remember(playerConnection) { try { playerConnection.service.castConnectionHandler } catch (e: Exception) { null } } val isCasting by castHandler?.isCasting?.collectAsState() ?: remember { mutableStateOf(false) } val castVolume by castHandler?.castVolume?.collectAsState() ?: remember { mutableFloatStateOf(1f) } val castDeviceName by castHandler?.castDeviceName?.collectAsState() ?: remember { mutableStateOf(null) } val librarySong by database.song(mediaMetadata.id).collectAsState(initial = null) val coroutineScope = rememberCoroutineScope() 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 showListenTogetherDialog by rememberSaveable { mutableStateOf(false) } val listenTogetherManager = LocalListenTogetherManager.current val listenTogetherRoleState = listenTogetherManager?.role?.collectAsState(initial = com.metrolist.music.listentogether.RoomRole.NONE) val isListenTogetherGuest = listenTogetherRoleState?.value == com.metrolist.music.listentogether.RoomRole.GUEST val pendingSuggestions by listenTogetherManager?.pendingSuggestions?.collectAsState(initial = emptyList()) ?: remember { mutableStateOf(emptyList()) } AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, onGetSong = { playlist -> database.withTransaction { insert(mediaMetadata) } coroutineScope.launch(Dispatchers.IO) { playlist.playlist.browseId?.let { YouTube.addToPlaylist(it, mediaMetadata.id) } } listOf(mediaMetadata.id) }, onDismiss = { showChoosePlaylistDialog = false }, ) ListenTogetherDialog( visible = showListenTogetherDialog, mediaMetadata = mediaMetadata, onDismiss = { showListenTogetherDialog = false }, ) 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) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) .padding(top = 24.dp, bottom = 6.dp), ) { // Show Cast indicator when casting if (isCasting && castDeviceName != null) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), ) { Icon( painter = painterResource(R.drawable.cast), contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.width(10.dp)) Text( text = stringResource(R.string.casting_to, castDeviceName ?: ""), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, ) } } VolumeSlider( value = if (isCasting) castVolume else playerVolume.value, onValueChange = { volume -> if (isCasting) { castHandler?.setVolume(volume) } else { playerConnection.service.playerVolume.value = volume } }, modifier = Modifier.fillMaxWidth(), accentColor = MaterialTheme.colorScheme.primary, ) } } Spacer(modifier = Modifier.height(20.dp)) HorizontalDivider() Spacer(modifier = Modifier.height(12.dp)) val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { val startingRadioText = stringResource(R.string.starting_radio) NewActionGrid( actions = listOfNotNull( if (!isListenTogetherGuest) { NewAction( icon = { Icon( painter = painterResource(R.drawable.radio), contentDescription = null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.start_radio), onClick = { Toast.makeText(context, startingRadioText, Toast.LENGTH_SHORT).show() playerConnection.startRadioSeamlessly() onDismiss() }, ) } else { null }, NewAction( icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.add_to_playlist), onClick = { showChoosePlaylistDialog = true }, ), NewAction( icon = { Icon( painter = painterResource(R.drawable.link), contentDescription = null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.copy_link), onClick = { val clipboard = context.getSystemService( android.content.Context.CLIPBOARD_SERVICE, ) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText( "Song Link", "https://music.youtube.com/watch?v=${mediaMetadata.id}", ) clipboard.setPrimaryClip(clip) android.widget.Toast .makeText(context, R.string.link_copied, android.widget.Toast.LENGTH_SHORT) .show() onDismiss() }, ), ), columns = if (isListenTogetherGuest) 2 else 3, modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), ) } item { // Check if this is a podcast episode (album ID doesn't start with MPREb_) val isPodcast = mediaMetadata.album?.let { !it.id.startsWith("MPREb_") } ?: false Material3MenuGroup( items = buildList { // Don't show "View Artist" for podcasts - only show "View Podcast" if (artists.isNotEmpty() && !isPodcast) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.view_artist)) }, description = { Text( text = mediaMetadata.artists.joinToString { it.name }, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, icon = { Icon( painter = painterResource(R.drawable.artist), contentDescription = null, modifier = Modifier.size(24.dp), ) }, onClick = { if (mediaMetadata.artists.size == 1) { navController.navigate("artist/${mediaMetadata.artists[0].id}") playerBottomSheetState.collapseSoft() onDismiss() } else { showSelectArtistDialog = true } }, ), ) } if (mediaMetadata.album != null) { add( Material3MenuItemData( title = { Text(text = stringResource(if (isPodcast) R.string.view_podcast else R.string.view_album)) }, description = { Text( text = mediaMetadata.album.title, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, icon = { Icon( painter = painterResource(if (isPodcast) R.drawable.mic else R.drawable.album), contentDescription = null, modifier = Modifier.size(24.dp), ) }, onClick = { if (isPodcast) { navController.navigate("online_podcast/${mediaMetadata.album.id}") } else { navController.navigate("album/${mediaMetadata.album.id}") } playerBottomSheetState.collapseSoft() onDismiss() }, ), ) } // Add to Library option val isInLibrary = librarySong?.song?.inLibrary != null add( Material3MenuItemData( title = { Text( text = stringResource( if (isInLibrary) { R.string.remove_from_library } else { R.string.add_to_library }, ), ) }, icon = { Icon( painter = painterResource( if (isInLibrary) { R.drawable.library_add_check } else { R.drawable.library_add }, ), contentDescription = null, modifier = Modifier.size(24.dp), ) }, onClick = { playerConnection.toggleLibrary() onDismiss() }, ), ) }, ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = listOf( when (download?.state) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download), ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, modifier = Modifier.size(24.dp), ) }, onClick = { DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, mediaMetadata.id, false, ) }, ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, mediaMetadata.id, false, ) }, ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, modifier = Modifier.size(24.dp), ) }, onClick = { 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, ) }, ) } }, ), ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = buildList { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.listen_together)) }, icon = { // Show a small badge when there are pending suggestions Box { Icon( painter = painterResource(R.drawable.group), contentDescription = null, modifier = Modifier.size(24.dp), ) if (pendingSuggestions.isNotEmpty()) { Surface( shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.primary, modifier = Modifier .offset(x = 8.dp, y = (-6).dp) .align(Alignment.TopEnd), ) { Text( text = pendingSuggestions.size.toString(), color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall, ) } } } }, onClick = { showListenTogetherDialog = true }, ), ) if (isListenTogetherGuest) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.resync)) }, icon = { Icon( painter = painterResource(R.drawable.replay), contentDescription = null, modifier = Modifier.size(24.dp), ) }, onClick = { listenTogetherManager.requestSync() onDismiss() }, ), ) } }, ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = buildList { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.details)) }, description = { Text(text = stringResource(R.string.details_desc)) }, icon = { Icon( painter = painterResource(R.drawable.info), contentDescription = null, modifier = Modifier.size(24.dp), ) }, onClick = { onShowDetailsDialog() onDismiss() }, ), ) if (isQueueTrigger != true) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.equalizer)) }, description = { Text(text = stringResource(R.string.equalizer_desc)) }, icon = { Icon( painter = painterResource(R.drawable.equalizer), contentDescription = null, modifier = Modifier.size(24.dp), ) }, onClick = { navController.navigate("equalizer") onDismiss() }, ), ) add( Material3MenuItemData( title = { Text(text = stringResource(R.string.advanced)) }, description = { Text(text = stringResource(R.string.advanced_desc)) }, icon = { Icon( painter = painterResource(R.drawable.tune), contentDescription = null, modifier = Modifier.size(24.dp), ) }, onClick = { 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)) } val listenTogetherManager = com.metrolist.music.LocalListenTogetherManager.current val isInRoom = listenTogetherManager?.isInRoom ?: false 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 { if (!isInRoom) { 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, ) } } } @Composable fun ListenTogetherDialog( visible: Boolean, mediaMetadata: MediaMetadata?, onDismiss: () -> Unit, ) { if (!visible) return val context = LocalContext.current val listenTogetherManager = com.metrolist.music.LocalListenTogetherManager.current val joiningRoomTemplate = stringResource(R.string.joining_room) // Handle case where manager is not available if (listenTogetherManager == null) { ListDialog(onDismiss = onDismiss) { item { Column( modifier = Modifier .fillMaxWidth() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( painter = painterResource(R.drawable.group), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp), ) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.listen_together), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.listen_together_not_configured), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(24.dp)) Button( onClick = onDismiss, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, ), ) { Text(stringResource(android.R.string.ok)) } } } } return } val connectionState by listenTogetherManager.connectionState.collectAsState() val roomState by listenTogetherManager.roomState.collectAsState() val userId by listenTogetherManager.userId.collectAsState() val pendingJoinRequests by listenTogetherManager.pendingJoinRequests.collectAsState() val pendingSuggestions by listenTogetherManager.pendingSuggestions.collectAsState() // Load saved username var savedUsername by rememberPreference(com.metrolist.music.constants.ListenTogetherUsernameKey, "") var roomCodeInput by rememberSaveable { mutableStateOf("") } var usernameInput by rememberSaveable { mutableStateOf(savedUsername) } // Local UI state for join/create actions var isCreatingRoom by rememberSaveable { mutableStateOf(false) } var isJoiningRoom by rememberSaveable { mutableStateOf(false) } var joinErrorMessage by rememberSaveable { mutableStateOf(null) } // User action menu state var selectedUserForMenu by rememberSaveable { mutableStateOf(null) } var selectedUsername by rememberSaveable { mutableStateOf(null) } // Localized helper strings val waitingForApprovalText = stringResource(R.string.waiting_for_approval) val invalidRoomCodeText = stringResource(R.string.invalid_room_code) val joinRequestDeniedText = stringResource(R.string.join_request_denied) // User action menu dialog if (selectedUserForMenu != null && selectedUsername != null) { ListDialog( onDismiss = { selectedUserForMenu = null selectedUsername = null }, ) { item { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { Icon( painter = painterResource(R.drawable.group), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(40.dp), ) Spacer(modifier = Modifier.width(16.dp)) Column { Text( text = stringResource(R.string.manage_user), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) Text( text = selectedUsername ?: "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } item { Spacer(modifier = Modifier.height(12.dp)) } // Kick button item { Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) .clickable { selectedUserForMenu?.let { listenTogetherManager.kickUser(it, "Removed by host") } selectedUserForMenu = null selectedUsername = null }, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.errorContainer, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp), ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.kick_user), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error, ) Text( text = stringResource(R.string.kick_user_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } item { Spacer(modifier = Modifier.height(8.dp)) } // Permanently kick button item { Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) .clickable { selectedUserForMenu?.let { userId -> selectedUsername?.let { username -> listenTogetherManager.blockUser(username) listenTogetherManager.kickUser(userId, R.string.user_blocked_by_host.toString()) } } selectedUserForMenu = null selectedUsername = null }, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceVariant, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp), ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.permanently_kick_user), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) Text( text = stringResource(R.string.permanently_kick_user_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } item { Spacer(modifier = Modifier.height(8.dp)) } // Transfer ownership button item { Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp) .clickable { selectedUserForMenu?.let { listenTogetherManager.transferHost(it) } selectedUserForMenu = null selectedUsername = null }, shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.primaryContainer, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp), ) { Icon( painter = painterResource(R.drawable.crown), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.transfer_ownership), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(R.string.transfer_ownership_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } item { Spacer(modifier = Modifier.height(16.dp)) } } return } // Sync usernameInput when savedUsername changes LaunchedEffect(savedUsername) { if (usernameInput.isBlank() && savedUsername.isNotBlank()) { usernameInput = savedUsername } } // Listen to low level events to update UI state (join rejected, approved, room created) LaunchedEffect(listenTogetherManager) { listenTogetherManager.events.collect { event -> when (event) { is ListenTogetherEvent.JoinRejected -> { val reason = event.reason joinErrorMessage = when { reason.isNullOrBlank() -> joinRequestDeniedText reason.contains("invalid", ignoreCase = true) == true -> invalidRoomCodeText else -> "$joinRequestDeniedText: $reason" } isJoiningRoom = false isCreatingRoom = false } is ListenTogetherEvent.JoinApproved -> { isJoiningRoom = false joinErrorMessage = null } is ListenTogetherEvent.RoomCreated -> { isCreatingRoom = false val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText("ListenTogetherRoom", event.roomCode) clipboard.setPrimaryClip(clip) } else -> { /* ignore other events here */ } } } } // Check if already in a room val isInRoom = listenTogetherManager.isInRoom val isHost = roomState?.hostId == userId ListDialog(onDismiss = onDismiss) { // Header - Icon on left, text left-aligned item { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { Icon( painter = painterResource(R.drawable.group), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(40.dp), ) Spacer(modifier = Modifier.width(16.dp)) Text( text = if (isInRoom) { if (isHost) stringResource(R.string.hosting_room) else stringResource(R.string.in_room) } else { stringResource(R.string.listen_together) }, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) } } // Connection status item { Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), shape = RoundedCornerShape(16.dp), color = when (connectionState) { ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f) ConnectionState.ERROR -> MaterialTheme.colorScheme.error.copy(alpha = 0.15f) ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceVariant }, ) { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { Box( modifier = Modifier .size(10.dp) .background( color = when (connectionState) { ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondary ConnectionState.ERROR -> MaterialTheme.colorScheme.error ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.outline }, shape = RoundedCornerShape(50), ), ) Spacer(modifier = Modifier.width(8.dp)) Text( text = when (connectionState) { ConnectionState.CONNECTED -> stringResource(R.string.listen_together_connected) ConnectionState.CONNECTING -> stringResource(R.string.listen_together_connecting) ConnectionState.RECONNECTING -> stringResource(R.string.listen_together_reconnecting) ConnectionState.ERROR -> stringResource(R.string.listen_together_error) ConnectionState.DISCONNECTED -> stringResource(R.string.listen_together_disconnected) }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = when (connectionState) { ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondary ConnectionState.ERROR -> MaterialTheme.colorScheme.error ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.onSurfaceVariant }, ) } if (connectionState == ConnectionState.CONNECTING || connectionState == ConnectionState.RECONNECTING) { Spacer(modifier = Modifier.height(12.dp)) LinearProgressIndicator( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.primary, ) } Spacer(modifier = Modifier.height(12.dp)) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { if (connectionState == ConnectionState.DISCONNECTED || connectionState == ConnectionState.ERROR) { Button( onClick = { listenTogetherManager.connect() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, ), ) { Text(stringResource(R.string.connect), fontWeight = FontWeight.SemiBold) } } else { Button( onClick = { listenTogetherManager.disconnect() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, ), ) { Text(stringResource(R.string.disconnect), fontWeight = FontWeight.SemiBold) } FilledTonalButton( onClick = { listenTogetherManager.forceReconnect() }, modifier = Modifier.weight(1f), ) { Text("Reconnect", fontWeight = FontWeight.SemiBold) } } } } } } item { Spacer(modifier = Modifier.height(12.dp)) } if (connectionState == ConnectionState.CONNECTED && !isInRoom) { item { Text( text = stringResource(R.string.listen_together_background_disconnect_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp), textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(12.dp)) } } if (isInRoom) { // Room status card roomState?.let { room -> item { Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), ) { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(R.string.room_code), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(4.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { Text( text = room.roomCode, style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, letterSpacing = 6.sp, ) } if (isHost) { Spacer(modifier = Modifier.height(12.dp)) val inviteLink = remember(room.roomCode) { "https://metrolist.meowery.eu/listen?code=${room.roomCode}" } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { FilledTonalButton( onClick = { val clipboard = context.getSystemService( Context.CLIPBOARD_SERVICE, ) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText("Listen Together Link", inviteLink) clipboard.setPrimaryClip(clip) Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() }, ) { Icon( painter = painterResource(R.drawable.link), contentDescription = stringResource(R.string.copy_link), modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.copy_link)) } Spacer(modifier = Modifier.width(8.dp)) FilledTonalButton( onClick = { val clipboard = context.getSystemService( Context.CLIPBOARD_SERVICE, ) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText("Room Code", room.roomCode) clipboard.setPrimaryClip(clip) Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() }, ) { Icon( painter = painterResource(R.drawable.content_copy), contentDescription = stringResource(R.string.copy_code), modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.copy_code)) } } } } } } item { Spacer(modifier = Modifier.height(16.dp)) } // Connected users - horizontal layout val connectedUsers = room.users.filter { it.isConnected } item { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) { Text( text = stringResource(R.string.connected_users, connectedUsers.size), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 12.dp), ) // Horizontal scrollable row for users Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { connectedUsers.forEach { user -> // User avatar card Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .width(72.dp) .clickable( enabled = isHost && user.userId != userId, onClick = { selectedUserForMenu = user.userId selectedUsername = user.username }, ), ) { // Circular avatar Box( contentAlignment = Alignment.Center, ) { Surface( modifier = Modifier.size(52.dp), shape = RoundedCornerShape(50), color = if (user.isHost) { MaterialTheme.colorScheme.primary } else if (user.userId == userId) { MaterialTheme.colorScheme.secondary } else { MaterialTheme.colorScheme.surfaceVariant }, ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Text( text = user.username.take(1).uppercase(), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = if (user.isHost) { MaterialTheme.colorScheme.onPrimary } else if (user.userId == userId) { MaterialTheme.colorScheme.onSecondary } else { MaterialTheme.colorScheme.onSurfaceVariant }, ) } } // Host/You badge if (user.isHost || user.userId == userId) { Surface( modifier = Modifier .align(Alignment.BottomEnd) .offset(x = 4.dp, y = 4.dp) .size(18.dp), shape = RoundedCornerShape(50), color = if (user.isHost) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Icon( painter = painterResource( if (user.isHost) R.drawable.crown else R.drawable.person, ), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(12.dp), ) } } } } Spacer(modifier = Modifier.height(6.dp)) // Username Text( text = user.username, style = MaterialTheme.typography.labelMedium, fontWeight = if (user.userId == userId) FontWeight.Bold else FontWeight.Medium, color = if (user.isHost) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface }, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) // Role label if (user.isHost) { Text( text = stringResource(R.string.host_label), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), ) } else if (user.userId == userId) { Text( text = stringResource(R.string.you_label), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f), ) } } } } } } // Pending join requests (host only) if (isHost && pendingJoinRequests.isNotEmpty()) { item { Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(R.string.pending_requests), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(8.dp)) } items(pendingJoinRequests) { request -> Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.padding(12.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f), ) { Surface( modifier = Modifier.size(36.dp), shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.secondary, ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Text( text = request.username.take(1).uppercase(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondary, ) } } Text( text = request.username, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { IconButton( onClick = { listenTogetherManager.approveJoin(request.userId) }, ) { Icon( painter = painterResource(R.drawable.check), contentDescription = stringResource(R.string.approve), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) } IconButton( onClick = { listenTogetherManager.rejectJoin(request.userId, "Rejected by host") }, ) { Icon( painter = painterResource(R.drawable.close), contentDescription = stringResource(R.string.reject), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), ) } } } } } } // Pending suggestions (host only) if (isHost && pendingSuggestions.isNotEmpty()) { item { Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(R.string.pending_suggestions), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(8.dp)) } items(pendingSuggestions) { suggestion -> Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.15f), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.padding(12.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.weight(1f), ) { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) Column(modifier = Modifier.weight(1f)) { Text( text = suggestion.trackInfo.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = suggestion.fromUsername, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { IconButton( onClick = { listenTogetherManager.approveSuggestion(suggestion.suggestionId) }, ) { Icon( painter = painterResource(R.drawable.check), contentDescription = stringResource(R.string.approve), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) } IconButton( onClick = { listenTogetherManager.rejectSuggestion(suggestion.suggestionId, "Rejected by host") }, ) { Icon( painter = painterResource(R.drawable.close), contentDescription = stringResource(R.string.reject), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), ) } } } } } } // Leave room button item { Spacer(modifier = Modifier.height(20.dp)) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { TextButton( onClick = onDismiss, modifier = Modifier.weight(1f), ) { Text( stringResource(R.string.cancel), fontWeight = FontWeight.Medium, ) } Button( onClick = { listenTogetherManager.leaveRoom() onDismiss() }, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, ), ) { Icon( painter = painterResource(R.drawable.logout), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.leave_room), fontWeight = FontWeight.SemiBold) } } Spacer(modifier = Modifier.height(16.dp)) } } } else { // Join/Create room section item { Surface( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( text = stringResource(R.string.listen_together_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) OutlinedTextField( value = usernameInput, onValueChange = { usernameInput = it }, label = { Text(stringResource(R.string.username)) }, placeholder = { Text(stringResource(R.string.enter_username)) }, leadingIcon = { Icon( painterResource(R.drawable.person), null, tint = MaterialTheme.colorScheme.primary, ) }, trailingIcon = { if (usernameInput.isNotBlank()) { IconButton(onClick = { usernameInput = "" }) { Icon(painterResource(R.drawable.close), null) } } }, singleLine = true, shape = RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = MaterialTheme.colorScheme.outline, focusedLabelColor = MaterialTheme.colorScheme.primary, ), modifier = Modifier.fillMaxWidth(), ) HorizontalDivider() Text( text = stringResource(R.string.join_existing_room), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) OutlinedTextField( value = roomCodeInput, onValueChange = { roomCodeInput = it.uppercase().filter { c -> c.isLetterOrDigit() }.take(8) }, label = { Text(stringResource(R.string.room_code)) }, placeholder = { Text("ABCD1234") }, supportingText = { Text( text = "${roomCodeInput.length}/8", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, leadingIcon = { Icon( painterResource(R.drawable.token), null, tint = MaterialTheme.colorScheme.primary, ) }, singleLine = true, shape = RoundedCornerShape(12.dp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = MaterialTheme.colorScheme.outline, focusedLabelColor = MaterialTheme.colorScheme.primary, ), modifier = Modifier.fillMaxWidth(), ) // Status messages if (isJoiningRoom) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), ) { CircularProgressIndicator( modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = waitingForApprovalText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium, ) } } joinErrorMessage?.let { msg -> Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.padding(12.dp), ) { Icon( painterResource(R.drawable.error), contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.error, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = msg, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Medium, ) } } } } } } // Action buttons item { Spacer(modifier = Modifier.height(20.dp)) Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { // Create Room button (left side) Button( onClick = { val username = usernameInput.takeIf { it.isNotBlank() } ?: savedUsername val finalUsername = username.trim() if (finalUsername.isNotBlank()) { savedUsername = finalUsername Toast.makeText(context, R.string.creating_room, Toast.LENGTH_SHORT).show() isCreatingRoom = true isJoiningRoom = false joinErrorMessage = null listenTogetherManager.connect() listenTogetherManager.createRoom(finalUsername) } else { Toast.makeText(context, R.string.error_username_empty, Toast.LENGTH_SHORT).show() } }, modifier = Modifier.weight(1f), enabled = (usernameInput.trim().isNotBlank() || savedUsername.isNotBlank()), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, ), ) { Icon( painter = painterResource(R.drawable.add), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.create_room), fontWeight = FontWeight.SemiBold) } // Join Room button (right side - only visible when room code is complete) if (roomCodeInput.length == 8) { Button( onClick = { val username = usernameInput.takeIf { it.isNotBlank() } ?: savedUsername val finalUsername = username.trim() if (finalUsername.isNotBlank()) { savedUsername = finalUsername Toast .makeText( context, String.format(joiningRoomTemplate, roomCodeInput), Toast.LENGTH_SHORT, ).show() isJoiningRoom = true isCreatingRoom = false joinErrorMessage = null listenTogetherManager.connect() listenTogetherManager.joinRoom(roomCodeInput, finalUsername) } else { Toast.makeText(context, R.string.error_username_empty, Toast.LENGTH_SHORT).show() } }, modifier = Modifier.weight(1f), enabled = (usernameInput.trim().isNotBlank() || savedUsername.isNotBlank()), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.secondary, ), ) { Icon( painter = painterResource(R.drawable.login), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.join_room), fontWeight = FontWeight.SemiBold) } } } TextButton( onClick = onDismiss, modifier = Modifier.fillMaxWidth(), ) { Text( stringResource(R.string.cancel), fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } Spacer(modifier = Modifier.height(16.dp)) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/PlaylistMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.content.Intent import android.content.res.Configuration import android.widget.Toast import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer 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.LazyColumn import androidx.compose.material3.CircularProgressIndicator 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.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration 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.metrolist.innertube.YouTube import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.PlaylistSong import com.metrolist.music.db.entities.Song import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.PlaylistListItem import com.metrolist.music.ui.component.TextFieldDialog import com.metrolist.music.ui.menu.ExportDialog import com.metrolist.music.utils.PlaylistExporter import com.metrolist.music.utils.getExportFileUri import com.metrolist.music.utils.saveToPublicDocuments 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 listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost 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 val isPinned by database.speedDialDao.isPinned(playlist.id).collectAsState(initial = false) var showExportDialog by remember { mutableStateOf(false) } 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)) } }, ) } 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() Spacer(modifier = Modifier.height(12.dp)) val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = listOfNotNull( if (!isGuest) { NewAction( icon = { Icon( painter = painterResource(R.drawable.play), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.play), onClick = { onDismiss() if (songs.isNotEmpty()) { playerConnection.playQueue( ListQueue( title = playlist.playlist.name, items = songs.map(Song::toMediaItem), ), ) } }, ) NewAction( icon = { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.shuffle), onClick = { onDismiss() if (songs.isNotEmpty()) { playerConnection.playQueue( ListQueue( title = playlist.playlist.name, items = songs.shuffled().map(Song::toMediaItem), ), ) } }, ) } else { null }, NewAction( icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.share), onClick = { onDismiss() val intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra( Intent.EXTRA_TEXT, "https://music.youtube.com/playlist?list=${dbPlaylist?.playlist?.browseId}", ) } context.startActivity(Intent.createChooser(intent, null)) }, ), ), modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), columns = if (isGuest) 1 else 3, ) } item { Material3MenuGroup( items = buildList { if (!isGuest) { playlist.playlist.browseId?.let { browseId -> add( Material3MenuItemData( title = { Text(text = stringResource(R.string.start_radio)) }, description = { Text(text = stringResource(R.string.start_radio_desc)) }, icon = { Icon( painter = painterResource(R.drawable.radio), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { YouTube.playlist(browseId).getOrNull()?.playlist?.let { playlistItem -> playlistItem.radioEndpoint?.let { radioEndpoint -> withContext(Dispatchers.Main) { playerConnection.playQueue(YouTubeQueue(radioEndpoint)) } } } } onDismiss() }, ), ) } } if (!isGuest) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.play_next)) }, description = { Text(text = stringResource(R.string.play_next_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_play), contentDescription = null, ) }, onClick = { coroutineScope.launch { playerConnection.playNext(songs.map { it.toMediaItem() }) } onDismiss() }, ), ) } if (!isGuest) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_queue)) }, description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.addToQueue(songs.map { it.toMediaItem() }) }, ), ) } }, ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = buildList { if (editable && autoPlaylist != true && !isGuest) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.edit)) }, description = { Text(text = stringResource(R.string.edit_desc)) }, icon = { Icon( painter = painterResource(R.drawable.edit), contentDescription = null, ) }, onClick = { showEditDialog = true }, ), ) } add( Material3MenuItemData( title = { Text( text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial", ) }, icon = { Icon( painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { if (isPinned) { database.speedDialDao.delete(playlist.id) } else { database.speedDialDao.insert( SpeedDialItem( id = playlist.id, title = playlist.playlist.name, subtitle = null, thumbnailUrl = playlist.thumbnails.firstOrNull(), type = "LOCAL_PLAYLIST", ), ) } } onDismiss() }, ), ) if (downloadPlaylist != true) { add( when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download), ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, ) }, onClick = { showRemoveDownloadDialog = true }, ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { showRemoveDownloadDialog = true }, ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, description = { Text(text = stringResource(R.string.download_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { 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, ) } }, ) } }, ) } // Export playlist add( Material3MenuItemData( title = { Text(text = stringResource(R.string.export_playlist)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, onClick = { showExportDialog = true }, ), ) if (autoPlaylist != true && !isGuest) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.delete)) }, description = { Text(text = stringResource(R.string.delete_desc)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { showDeletePlaylistDialog = true }, ), ) } playlist.playlist.shareLink?.let { shareLink -> add( Material3MenuItemData( title = { Text(text = stringResource(R.string.share)) }, description = { Text(text = stringResource(R.string.share_desc)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, onClick = { val intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, shareLink) } context.startActivity(Intent.createChooser(intent, null)) onDismiss() }, ), ) } }, ) } } val exportPlaylistStr = stringResource(R.string.export_playlist) if (showExportDialog) { ExportDialog( onDismiss = { showExportDialog = false }, onShare = { format -> val playlistSongs = songs.map { s -> com.metrolist.music.db.entities.PlaylistSong( map = com.metrolist.music.db.entities.PlaylistSongMap( songId = s.id, playlistId = playlist.id, position = 0, ), song = s, ) } val result = when (format) { "csv" -> PlaylistExporter.exportPlaylistAsCSV(context, playlist.playlist.name, playlistSongs) "m3u" -> PlaylistExporter.exportPlaylistAsM3U(context, playlist.playlist.name, playlistSongs) else -> Result.failure(IllegalArgumentException("Unknown format")) } result .onSuccess { file -> val uri = getExportFileUri(context, file) val mimeType = if (format == "csv") "text/csv" else "audio/x-mpegurl" val shareIntent = Intent(Intent.ACTION_SEND).apply { type = mimeType putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(shareIntent, exportPlaylistStr)) }.onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } showExportDialog = false }, onSave = { format -> val playlistSongs = songs.map { s -> com.metrolist.music.db.entities.PlaylistSong( map = com.metrolist.music.db.entities.PlaylistSongMap( songId = s.id, playlistId = playlist.id, position = 0, ), song = s, ) } val export = when (format) { "csv" -> PlaylistExporter.exportPlaylistAsCSV(context, playlist.playlist.name, playlistSongs) "m3u" -> PlaylistExporter.exportPlaylistAsM3U(context, playlist.playlist.name, playlistSongs) else -> Result.failure(IllegalArgumentException("Unknown format")) } export .onSuccess { file -> val mimeType = if (format == "csv") "text/csv" else "audio/x-mpegurl" val save = saveToPublicDocuments(context, file, mimeType) save .onSuccess { Toast.makeText(context, R.string.export_success, Toast.LENGTH_SHORT).show() } .onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } }.onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } showExportDialog = false }, ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/PlaylistScreenMenus.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.content.Context import android.content.Intent import android.net.Uri import android.widget.Toast import androidx.compose.foundation.clickable 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.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.unit.dp import androidx.media3.exoplayer.offline.Download import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.R import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.PlaylistSong import com.metrolist.music.db.entities.Song import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.utils.PlaylistExporter import com.metrolist.music.utils.getExportFileUri import com.metrolist.music.utils.saveToPublicDocuments import kotlinx.coroutines.launch /** * Menu for Local Playlist Screen */ @Composable fun LocalPlaylistMenu( playlist: Playlist, songs: List, context: Context, downloadState: Int, onEdit: () -> Unit, onSync: () -> Unit, onDelete: () -> Unit, onDownload: () -> Unit, onQueue: () -> Unit, onDismiss: () -> Unit, ) { val listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val coroutineScope = rememberCoroutineScope() val localContext = LocalContext.current val (showExportDialog, setShowExportDialog) = remember { mutableStateOf(false) } val downloadMenuItem = when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text(stringResource(R.string.remove_download)) }, description = { Text(stringResource(R.string.remove_download_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, ) }, onClick = { onDownload() onDismiss() }, ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(stringResource(R.string.downloading)) }, description = { Text(stringResource(R.string.download_in_progress_desc)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { onDownload() onDismiss() }, ) } else -> { Material3MenuItemData( title = { Text(stringResource(R.string.action_download)) }, description = { Text(stringResource(R.string.download_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { onDownload() onDismiss() }, ) } } val isYouTubePlaylist = playlist.playlist.browseId != null val menuItems = buildList { add( Material3MenuItemData( title = { Text(stringResource(R.string.edit)) }, description = { Text(stringResource(R.string.edit_playlist)) }, icon = { Icon( painter = painterResource(R.drawable.edit), contentDescription = null, ) }, onClick = { onEdit() onDismiss() }, ), ) // Show sync button only for YouTube playlists if (isYouTubePlaylist) { add( Material3MenuItemData( title = { Text(stringResource(R.string.action_sync)) }, description = { Text(stringResource(R.string.sync_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.sync), contentDescription = null, ) }, onClick = { onSync() onDismiss() }, ), ) } if (!isGuest) { add( Material3MenuItemData( title = { Text(stringResource(R.string.add_to_queue)) }, description = { Text(stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onQueue() onDismiss() }, ), ) } add(downloadMenuItem) add( Material3MenuItemData( title = { Text(stringResource(R.string.share)) }, description = { Text(stringResource(R.string.share_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, onClick = { val shareText = if (isYouTubePlaylist) { "https://music.youtube.com/playlist?list=${playlist.playlist.browseId}" } else { songs.joinToString("\n") { it.song.song.title } } val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, shareText) type = "text/plain" } val shareIntent = Intent.createChooser(sendIntent, null) context.startActivity(shareIntent) onDismiss() }, ), ) // Export menu group add( Material3MenuItemData( title = { Text(stringResource(R.string.export_playlist)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, onClick = { setShowExportDialog(true) }, ), ) add( Material3MenuItemData( title = { Text(stringResource(R.string.delete)) }, description = { Text(stringResource(R.string.delete_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { onDelete() onDismiss() }, ), ) } Material3MenuGroup(items = menuItems) if (showExportDialog) { ExportDialog( onDismiss = { setShowExportDialog(false) }, onShare = { format -> coroutineScope.launch { val result = when (format) { "csv" -> PlaylistExporter.exportPlaylistAsCSV(localContext, playlist.playlist.name, songs) "m3u" -> PlaylistExporter.exportPlaylistAsM3U(localContext, playlist.playlist.name, songs) else -> Result.failure(IllegalArgumentException("Unknown format")) } result .onSuccess { file -> val uri = getExportFileUri(localContext, file) val mimeType = when (format) { "csv" -> "text/csv" "m3u" -> "audio/x-mpegurl" else -> "*/*" } shareExportFile(localContext, uri, mimeType) }.onFailure { Toast.makeText(localContext, R.string.export_failed, Toast.LENGTH_SHORT).show() } } onDismiss() }, onSave = { format -> coroutineScope.launch { val exportResult = when (format) { "csv" -> PlaylistExporter.exportPlaylistAsCSV(localContext, playlist.playlist.name, songs) "m3u" -> PlaylistExporter.exportPlaylistAsM3U(localContext, playlist.playlist.name, songs) else -> Result.failure(IllegalArgumentException("Unknown format")) } exportResult .onSuccess { file -> val mimeType = when (format) { "csv" -> "text/csv" "m3u" -> "audio/x-mpegurl" else -> "application/octet-stream" } val saveResult = saveToPublicDocuments(localContext, file, mimeType) saveResult .onSuccess { Toast.makeText(localContext, R.string.export_success, Toast.LENGTH_SHORT).show() }.onFailure { Toast.makeText(localContext, R.string.export_failed, Toast.LENGTH_SHORT).show() } }.onFailure { Toast.makeText(localContext, R.string.export_failed, Toast.LENGTH_SHORT).show() } } onDismiss() }, ) } } /** * Menu for Auto Playlist Screen (Liked Songs, Downloaded Songs, etc.) */ @Composable fun AutoPlaylistMenu( downloadState: Int, onQueue: () -> Unit, onDownload: () -> Unit, onDismiss: () -> Unit, songs: List = emptyList(), playlistName: String = "Playlist", ) { val listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val coroutineScope = rememberCoroutineScope() val localContext = LocalContext.current val (showExportDialog, setShowExportDialog) = remember { mutableStateOf(false) } val downloadMenuItem = when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text(stringResource(R.string.remove_download)) }, description = { Text(stringResource(R.string.remove_download_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, ) }, onClick = { onDownload() onDismiss() }, ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(stringResource(R.string.downloading)) }, description = { Text(stringResource(R.string.download_in_progress_desc)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { onDownload() onDismiss() }, ) } else -> { Material3MenuItemData( title = { Text(stringResource(R.string.action_download)) }, description = { Text(stringResource(R.string.download_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { onDownload() onDismiss() }, ) } } Material3MenuGroup( items = listOfNotNull( if (!isGuest) { Material3MenuItemData( title = { Text(stringResource(R.string.add_to_queue)) }, description = { Text(stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onQueue() onDismiss() }, ) } else { null }, if (songs.isNotEmpty()) { Material3MenuItemData( title = { Text(stringResource(R.string.export_playlist)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, onClick = { setShowExportDialog(true) }, ) } else { null }, downloadMenuItem, ), ) if (showExportDialog) { // Convert Song objects to a format that PlaylistExporter can handle val playlistSongs = songs.map { song -> PlaylistSong( map = com.metrolist.music.db.entities.PlaylistSongMap( songId = song.id, playlistId = "auto_playlist", position = 0, ), song = song, ) } ExportDialog( onDismiss = { setShowExportDialog(false) }, onShare = { format -> coroutineScope.launch { val result = when (format) { "csv" -> PlaylistExporter.exportPlaylistAsCSV(localContext, playlistName, playlistSongs) "m3u" -> PlaylistExporter.exportPlaylistAsM3U(localContext, playlistName, playlistSongs) else -> Result.failure(IllegalArgumentException("Unknown format")) } result .onSuccess { file -> val uri = getExportFileUri(localContext, file) val mimeType = when (format) { "csv" -> "text/csv" "m3u" -> "audio/x-mpegurl" else -> "*/*" } shareExportFile(localContext, uri, mimeType) }.onFailure { Toast.makeText(localContext, R.string.export_failed, Toast.LENGTH_SHORT).show() } } onDismiss() }, onSave = { format -> coroutineScope.launch { val exportResult = when (format) { "csv" -> PlaylistExporter.exportPlaylistAsCSV(localContext, playlistName, playlistSongs) "m3u" -> PlaylistExporter.exportPlaylistAsM3U(localContext, playlistName, playlistSongs) else -> Result.failure(IllegalArgumentException("Unknown format")) } exportResult .onSuccess { file -> val mimeType = when (format) { "csv" -> "text/csv" "m3u" -> "audio/x-mpegurl" else -> "application/octet-stream" } val saveResult = saveToPublicDocuments(localContext, file, mimeType) saveResult .onSuccess { Toast.makeText(localContext, R.string.export_success, Toast.LENGTH_SHORT).show() }.onFailure { Toast.makeText(localContext, R.string.export_failed, Toast.LENGTH_SHORT).show() } }.onFailure { Toast.makeText(localContext, R.string.export_failed, Toast.LENGTH_SHORT).show() } } onDismiss() }, ) } } /** * Menu for Top Playlist Screen */ @Composable fun TopPlaylistMenu( downloadState: Int, onQueue: () -> Unit, onDownload: () -> Unit, onDismiss: () -> Unit, ) { val listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val downloadMenuItem = when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text(stringResource(R.string.remove_download)) }, description = { Text(stringResource(R.string.remove_download_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, ) }, onClick = { onDownload() onDismiss() }, ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(stringResource(R.string.downloading)) }, description = { Text(stringResource(R.string.download_in_progress_desc)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { onDownload() onDismiss() }, ) } else -> { Material3MenuItemData( title = { Text(stringResource(R.string.action_download)) }, description = { Text(stringResource(R.string.download_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { onDownload() onDismiss() }, ) } } Material3MenuGroup( items = listOfNotNull( if (!isGuest) { Material3MenuItemData( title = { Text(stringResource(R.string.add_to_queue)) }, description = { Text(stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onQueue() onDismiss() }, ) } else { null }, downloadMenuItem, ), ) } private fun shareExportFile( context: Context, uri: Uri, mimeType: String, ) { val shareIntent = Intent(Intent.ACTION_SEND).apply { type = mimeType putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.export_playlist))) } @Composable fun ExportDialog( onDismiss: () -> Unit, initialFormat: String = "csv", onShare: (format: String) -> Unit, onSave: (format: String) -> Unit, ) { val (selected, setSelected) = remember { mutableStateOf(initialFormat) } DefaultDialog( onDismiss = onDismiss, title = { Text(stringResource(R.string.export_playlist)) }, buttons = { TextButton(onClick = onDismiss) { Text(text = stringResource(android.R.string.cancel)) } TextButton(onClick = { onSave(selected) }) { Text(text = stringResource(R.string.export_option_save)) } TextButton(onClick = { onShare(selected) }) { Text(text = stringResource(R.string.export_option_share)) } }, horizontalAlignment = Alignment.Start, ) { Column(modifier = Modifier.fillMaxWidth()) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { setSelected("csv") } .padding(horizontal = 8.dp, vertical = 8.dp), ) { RadioButton(selected = selected == "csv", onClick = null) Column(modifier = Modifier.padding(start = 12.dp)) { Text(text = stringResource(R.string.export_as_csv)) } } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { setSelected("m3u") } .padding(horizontal = 8.dp, vertical = 8.dp), ) { RadioButton(selected = selected == "m3u", onClick = null) Column(modifier = Modifier.padding(start = 12.dp)) { Text(text = stringResource(R.string.export_as_m3u)) } } } } } /** * Menu for Cache Playlist Screen */ @Composable fun CachePlaylistMenu( downloadState: Int, onQueue: () -> Unit, onDownload: () -> Unit, onDismiss: () -> Unit, ) { val listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val downloadMenuItem = when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text(stringResource(R.string.remove_download)) }, description = { Text(stringResource(R.string.remove_download_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, ) }, onClick = { onDownload() onDismiss() }, ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(stringResource(R.string.downloading)) }, description = { Text(stringResource(R.string.download_in_progress_desc)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { onDownload() onDismiss() }, ) } else -> { Material3MenuItemData( title = { Text(stringResource(R.string.action_download)) }, description = { Text(stringResource(R.string.download_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { onDownload() onDismiss() }, ) } } Material3MenuGroup( items = listOfNotNull( if (!isGuest) { Material3MenuItemData( title = { Text(stringResource(R.string.add_to_queue)) }, description = { Text(stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onQueue() onDismiss() }, ) } else { null }, downloadMenuItem, ), ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/QueueMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.content.Intent import android.content.res.Configuration import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween 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.Spacer 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.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator 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.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.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration 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 coil3.compose.AsyncImage import com.metrolist.innertube.YouTube import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.LocalSyncUtils import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.MediaMetadata import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.BottomSheetState import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.MediaMetadataListItem import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch @Composable fun QueueMenu( mediaMetadata: MediaMetadata?, navController: NavController, playerBottomSheetState: BottomSheetState, onShowDetailsDialog: () -> Unit, onDismiss: () -> Unit, ) { mediaMetadata ?: return val context = LocalContext.current val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return val coroutineScope = rememberCoroutineScope() val syncUtils = LocalSyncUtils.current val librarySong by database.song(mediaMetadata.id).collectAsState(initial = null) val download by LocalDownloadUtil.current.getDownload(mediaMetadata.id) .collectAsState(initial = null) var refetchIconDegree by remember { mutableFloatStateOf(0f) } val rotationAnimation by animateFloatAsState( targetValue = refetchIconDegree, animationSpec = tween(durationMillis = 800), label = "", ) val artists = remember(mediaMetadata.artists) { mediaMetadata.artists.filter { it.id != null } } var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) } AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, onGetSong = { playlist -> database.withTransaction { insert(mediaMetadata) } coroutineScope.launch(Dispatchers.IO) { playlist.playlist.browseId?.let { YouTube.addToPlaylist(it, mediaMetadata.id) } } listOf(mediaMetadata.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 playerBottomSheetState.collapseSoft() onDismiss() } .padding(horizontal = 12.dp), ) { Box( modifier = Modifier.padding(8.dp), contentAlignment = Alignment.Center, ) { AsyncImage( model = null, 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), ) } } } } // Song header with like button (for episodes, this toggles save for later) val isEpisode = librarySong?.song?.isEpisode == true || mediaMetadata.isEpisode val isFavorite = if (isEpisode) librarySong?.song?.inLibrary != null else librarySong?.song?.liked == true MediaMetadataListItem( mediaMetadata = mediaMetadata, trailingContent = { IconButton( onClick = { coroutineScope.launch(Dispatchers.IO) { database.transaction { if (librarySong == null) { insert(mediaMetadata) } } val dbSong = database.song(mediaMetadata.id).firstOrNull() dbSong?.let { songWithArtists -> val songEntity = songWithArtists.song if (songEntity.isEpisode) { // Episode: toggle save for later val isCurrentlySaved = songEntity.inLibrary != null database.query { update(songEntity.copy(inLibrary = if (isCurrentlySaved) null else java.time.LocalDateTime.now())) } launch { if (isCurrentlySaved) { val setVideoIdEntity = database.getSetVideoId(songEntity.id) val setVideoId = setVideoIdEntity?.setVideoId if (setVideoId != null) { YouTube.removeEpisodeFromSavedEpisodes(songEntity.id, setVideoId).onSuccess { timber.log.Timber.d("[EPISODE_SAVE] Removed episode from Episodes for Later: ${songEntity.id}") }.onFailure { e -> timber.log.Timber.e(e, "[EPISODE_SAVE] Failed to remove episode: ${songEntity.id}") kotlinx.coroutines.withContext(Dispatchers.Main) { android.widget.Toast.makeText(context, R.string.error_episode_remove, android.widget.Toast.LENGTH_SHORT).show() } } } } else { YouTube.addEpisodeToSavedEpisodes(songEntity.id).onSuccess { timber.log.Timber.d("[EPISODE_SAVE] Saved episode to Episodes for Later: ${songEntity.id}") }.onFailure { e -> timber.log.Timber.e(e, "[EPISODE_SAVE] Failed to save episode: ${songEntity.id}") kotlinx.coroutines.withContext(Dispatchers.Main) { android.widget.Toast.makeText(context, R.string.error_episode_save, android.widget.Toast.LENGTH_SHORT).show() } } } } } else { // Regular song: toggle like val s = songEntity.toggleLike() database.query { update(s) } syncUtils.likeSong(s) } } } }, ) { Icon( painter = painterResource( if (isFavorite) R.drawable.favorite else R.drawable.favorite_border ), tint = if (isFavorite) MaterialTheme.colorScheme.error else LocalContentColor.current, contentDescription = null, ) } }, ) HorizontalDivider() Spacer(modifier = Modifier.height(12.dp)) val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { // Quick actions grid item { NewActionGrid( actions = listOf( NewAction( icon = { Icon( painter = painterResource(R.drawable.radio), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.start_radio), onClick = { onDismiss() val currentMediaId = playerConnection.player.currentMediaItemIndex.let { playerConnection.player.getMediaItemAt(it).mediaId } if (mediaMetadata.id == currentMediaId) { playerConnection.startRadioSeamlessly() } else { playerConnection.playQueue( YouTubeQueue.radio(mediaMetadata) ) } } ), NewAction( icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.add_to_playlist), onClick = { showChoosePlaylistDialog = true } ), NewAction( icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.share), onClick = { onDismiss() 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)) } ) ), modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp) ) } // Play next / Add to queue item { Material3MenuGroup( items = listOf( Material3MenuItemData( title = { Text(text = stringResource(R.string.play_next)) }, description = { Text(text = stringResource(R.string.play_next_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_play), contentDescription = null, ) }, onClick = { onDismiss() librarySong?.let { playerConnection.playNext(it.toMediaItem()) } ?: run { playerConnection.playNext(mediaMetadata.toMediaItem()) } } ), Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_queue)) }, description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onDismiss() librarySong?.let { playerConnection.addToQueue(it.toMediaItem()) } ?: run { playerConnection.addToQueue(mediaMetadata.toMediaItem()) } } ) ) ) } item { Spacer(modifier = Modifier.height(12.dp)) } // Download section item { Material3MenuGroup( items = listOf( when (download?.state) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.remove_download)) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, modifier = Modifier.size(24.dp) ) }, onClick = { DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, mediaMetadata.id, false, ) } ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp ) }, onClick = { DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, mediaMetadata.id, false, ) } ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, description = { Text(text = stringResource(R.string.download_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, modifier = Modifier.size(24.dp) ) }, onClick = { 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, ) } ) } } ) ) } item { Spacer(modifier = Modifier.height(12.dp)) } // Navigation section (Artist, Album) item { Material3MenuGroup( items = buildList { if (artists.isNotEmpty()) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.view_artist)) }, description = { Text( text = mediaMetadata.artists.joinToString { it.name }, maxLines = 1, overflow = TextOverflow.Ellipsis ) }, icon = { Icon( painter = painterResource(R.drawable.artist), contentDescription = null, modifier = Modifier.size(24.dp) ) }, onClick = { if (mediaMetadata.artists.size == 1) { navController.navigate("artist/${mediaMetadata.artists[0].id}") playerBottomSheetState.collapseSoft() onDismiss() } else { showSelectArtistDialog = true } } ) ) } if (mediaMetadata.album != null) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.view_album)) }, description = { Text( text = mediaMetadata.album.title, maxLines = 1, overflow = TextOverflow.Ellipsis ) }, icon = { Icon( painter = painterResource(R.drawable.album), contentDescription = null, modifier = Modifier.size(24.dp) ) }, onClick = { navController.navigate("album/${mediaMetadata.album.id}") playerBottomSheetState.collapseSoft() onDismiss() } ) ) } } ) } item { Spacer(modifier = Modifier.height(12.dp)) } // Details and refetch section item { Material3MenuGroup( items = buildList { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.refetch)) }, description = { Text(text = stringResource(R.string.refetch_desc)) }, icon = { Icon( painter = painterResource(R.drawable.sync), contentDescription = null, modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation), ) }, onClick = { refetchIconDegree -= 360 coroutineScope.launch(Dispatchers.IO) { YouTube.queue(listOf(mediaMetadata.id)).onSuccess { val newSong = it.firstOrNull() if (newSong != null && librarySong != null) { database.transaction { update(librarySong!!, newSong.toMediaMetadata()) } } } } } ) ) add( Material3MenuItemData( title = { Text(text = stringResource(R.string.details)) }, description = { Text(text = stringResource(R.string.details_desc)) }, icon = { Icon( painter = painterResource(R.drawable.info), contentDescription = null, modifier = Modifier.size(24.dp) ) }, onClick = { onShowDetailsDialog() onDismiss() } ) ) } ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/SelectionSongsMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.annotation.SuppressLint import android.content.res.Configuration import android.widget.Toast import androidx.compose.foundation.layout.PaddingValues 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.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator 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.LocalConfiguration 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.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.metrolist.innertube.YouTube import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.LocalSyncUtils import com.metrolist.music.R import com.metrolist.music.db.entities.PlaylistSongMap import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.MediaMetadata import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.time.LocalDateTime @SuppressLint("MutableCollectionMutableState") @Composable fun SelectionSongMenu( songSelection: List, onDismiss: () -> Unit, clearAction: () -> Unit, songPosition: List? = emptyList(), isUploadedPlaylist: Boolean = false, ) { val context = LocalContext.current val database = LocalDatabase.current val downloadUtil = LocalDownloadUtil.current val coroutineScope = rememberCoroutineScope() val playerConnection = LocalPlayerConnection.current ?: return val syncUtils = LocalSyncUtils.current val deletedNSongsTemplate = stringResource(R.string.deleted_n_songs) val listenTogetherManager = com.metrolist.music.LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && listenTogetherManager.isHost == false 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) } var showDeleteUploadedDialog by remember { mutableStateOf(false) } var isDeleting by remember { mutableStateOf(false) } var deleteProgress by remember { mutableIntStateOf(0) } var totalToDelete by remember { mutableIntStateOf(0) } 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)) } }, ) } if (showDeleteUploadedDialog) { DefaultDialog( onDismiss = { if (!isDeleting) { showDeleteUploadedDialog = false } }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, title = { Text( if (isDeleting) { stringResource(R.string.deleting) } else { stringResource(R.string.delete_uploaded_songs) }, ) }, buttons = { if (!isDeleting) { TextButton( onClick = { showDeleteUploadedDialog = false }, ) { Text(text = stringResource(android.R.string.cancel)) } TextButton( onClick = { totalToDelete = songSelection.size deleteProgress = 0 isDeleting = true val songsToDelete = songSelection.toList() coroutineScope.launch(Dispatchers.IO) { var successCount = 0 songsToDelete.forEachIndexed { index, song -> deleteProgress = index + 1 val entityId = song.song.uploadEntityId if (entityId != null) { YouTube.deleteUploadedSong(entityId).onSuccess { database.query { delete(song.song) } successCount++ } } } withContext(Dispatchers.Main) { Toast .makeText( context, String.format(deletedNSongsTemplate, successCount), Toast.LENGTH_SHORT, ).show() isDeleting = false showDeleteUploadedDialog = false onDismiss() clearAction() } } }, ) { Text(text = stringResource(R.string.delete)) } } }, ) { if (isDeleting) { Text( text = stringResource(R.string.upload_progress, deleteProgress, totalToDelete), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(16.dp)) LinearProgressIndicator( progress = { if (totalToDelete > 0) deleteProgress.toFloat() / totalToDelete else 0f }, modifier = Modifier.fillMaxWidth(), ) } else { Text( text = stringResource(R.string.delete_uploaded_songs_confirm, songSelection.size), style = MaterialTheme.typography.bodyLarge, ) } } } val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = listOfNotNull( if (!isGuest) { NewAction( icon = { Icon( painter = painterResource(R.drawable.play), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.play), onClick = { onDismiss() playerConnection.playQueue( ListQueue( title = "Selection", items = songSelection.map { it.toMediaItem() }, ), ) clearAction() }, ) } else { null }, if (!isGuest) { NewAction( icon = { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.shuffle), onClick = { onDismiss() playerConnection.playQueue( ListQueue( title = "Selection", items = songSelection.shuffled().map { it.toMediaItem() }, ), ) clearAction() }, ) } else { null }, NewAction( icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.add_to_playlist), onClick = { showChoosePlaylistDialog = true }, ), ), modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), ) } item { Material3MenuGroup( items = buildList { if (!isGuest) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.play_next)) }, description = { Text(text = stringResource(R.string.play_next_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_play), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.playNext(songSelection.map { it.toMediaItem() }) clearAction() }, ), ) add( Material3MenuItemData( title = { Text(text = stringResource(R.string.shuffle)) }, description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.playQueue( ListQueue( title = "Selection", items = songSelection.shuffled().map { it.toMediaItem() }, ), ) clearAction() }, ), ) add( Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_queue)) }, description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.addToQueue(songSelection.map { it.toMediaItem() }) clearAction() }, ), ) } add( Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_playlist)) }, description = { Text(text = stringResource(R.string.add_to_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, ) }, onClick = { showChoosePlaylistDialog = true }, ), ) add( Material3MenuItemData( title = { Text( text = stringResource( if (allInLibrary) R.string.remove_from_library else R.string.add_to_library, ), ) }, icon = { Icon( painter = painterResource( if (allInLibrary) R.drawable.library_add_check else R.drawable.library_add, ), contentDescription = null, ) }, onClick = { if (allInLibrary) { database.query { songSelection.forEach { song -> inLibrary(song.id, null) } } coroutineScope.launch { // Use the new reliable method that fetches fresh tokens songSelection.forEach { song -> YouTube.toggleSongLibrary(song.id, false) } } } else { database.transaction { songSelection.forEach { song -> insert(song.toMediaMetadata()) inLibrary(song.id, LocalDateTime.now()) } } coroutineScope.launch { // Use the new reliable method that fetches fresh tokens songSelection .filter { it.song.inLibrary == null } .forEach { song -> YouTube.toggleSongLibrary(song.id, true) } } } }, ), ) }, ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = buildList { add( when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download), ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, ) }, onClick = { showRemoveDownloadDialog = true }, ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { showRemoveDownloadDialog = true }, ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { 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, ) } }, ) } }, ) add( Material3MenuItemData( title = { Text( text = stringResource( if (allLiked) R.string.dislike_all else R.string.like_all, ), ) }, icon = { Icon( painter = painterResource( if (allLiked) R.drawable.favorite else R.drawable.favorite_border, ), contentDescription = null, ) }, onClick = { val allLiked = songSelection.all { it.song.liked } onDismiss() database.query { songSelection.forEach { song -> if ((!allLiked && !song.song.liked) || allLiked) { val s = song.song.toggleLike() update(s) syncUtils.likeSong(s) } } } }, ), ) if (songPosition?.isNotEmpty() == true) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.delete)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { 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() }, ), ) } if (isUploadedPlaylist) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.delete_uploaded_songs)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { showDeleteUploadedDialog = true }, ), ) } }, ) } } } @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 listenTogetherManager = com.metrolist.music.LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && listenTogetherManager.isHost == false 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 = { songSelection.map { runBlocking { withContext(Dispatchers.IO) { database.insert(it) } } 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)) } }, ) } LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { Material3MenuGroup( items = buildList { if (currentItems.isNotEmpty() && !isGuest) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.delete)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { onDismiss() var i = 0 currentItems.forEach { cur -> if (playerConnection.player.availableCommands.contains( Player.COMMAND_CHANGE_MEDIA_ITEMS, ) ) { playerConnection.player.removeMediaItem(cur.firstPeriodIndex - i++) } } clearAction() }, ), ) } if (!isGuest) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.play)) }, icon = { Icon( painter = painterResource(R.drawable.play), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.playQueue( ListQueue( title = "Selection", items = songSelection.map { it.toMediaItem() }, ), ) clearAction() }, ), ) add( Material3MenuItemData( title = { Text(text = stringResource(R.string.shuffle)) }, icon = { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.playQueue( ListQueue( title = "Selection", items = songSelection.shuffled().map { it.toMediaItem() }, ), ) clearAction() }, ), ) if (!isGuest) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_queue)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.addToQueue(songSelection.map { it.toMediaItem() }) clearAction() }, ), ) } } add( Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_playlist)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, ) }, onClick = { showChoosePlaylistDialog = true }, ), ) }, ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = buildList { add( Material3MenuItemData( title = { Text( text = stringResource(R.string.like_all), ) }, icon = { Icon( painter = painterResource( if (allLiked) R.drawable.favorite else R.drawable.favorite_border, ), contentDescription = null, ) }, onClick = { database.query { if (allLiked) { songSelection.forEach { song -> update(song.toSongEntity().toggleLike()) } } else { songSelection.filter { !it.liked }.forEach { song -> update(song.toSongEntity().toggleLike()) } } } }, ), ) add( when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download), color = MaterialTheme.colorScheme.surface, ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, tint = MaterialTheme.colorScheme.surface, ) }, onClick = { showRemoveDownloadDialog = true }, cardColors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.onSurface, ), ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { showRemoveDownloadDialog = true }, ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { 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, ) } }, ) } }, ) }, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/SongMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.content.Intent import android.content.res.Configuration import java.time.LocalDateTime 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.Spacer 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.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape 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.material3.TextButton 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.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver 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.LocalConfiguration 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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadService import android.widget.Toast import androidx.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.innertube.YouTube import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.LocalSyncUtils import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.db.entities.ArtistEntity import com.metrolist.music.db.entities.Event import com.metrolist.music.db.entities.PodcastEntity import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.db.entities.PlaylistSong import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.LocalBottomSheetPageState import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.TextFieldDialog import com.metrolist.music.ui.utils.ShowMediaInfo import com.metrolist.music.viewmodels.CachePlaylistViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @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 listenTogetherManager = LocalListenTogetherManager.current val scope = rememberCoroutineScope() var refetchIconDegree by remember { mutableFloatStateOf(0f) } val cacheViewModel = hiltViewModel() val rotationAnimation by animateFloatAsState( targetValue = refetchIconDegree, animationSpec = tween(durationMillis = 800), label = "", ) val isPinned by database.speedDialDao.isPinned(song.id).collectAsState(initial = false) // Podcast subscription state for episodes val podcastEntity by produceState(initialValue = null, song) { val podcastId = song.song.albumId if (song.song.isEpisode && podcastId != null) { database.podcast(podcastId).collect { value = it } } } val isPodcastSubscribed = podcastEntity?.bookmarkedAt != null val orderedArtists by produceState(initialValue = emptyList(), song) { withContext(Dispatchers.IO) { val artistMaps = database.songArtistMap(song.id).sortedBy { it.position } val sorted = artistMaps.mapNotNull { map -> song.artists.firstOrNull { it.id == map.artistId } } value = sorted } } var showEditDialog by rememberSaveable { mutableStateOf(false) } val TextFieldValueSaver: Saver = Saver( save = { it.text }, restore = { text -> TextFieldValue(text, TextRange(text.length)) } ) var titleField by rememberSaveable(stateSaver = TextFieldValueSaver) { mutableStateOf(TextFieldValue(song.song.title)) } var artistField by rememberSaveable(stateSaver = TextFieldValueSaver) { mutableStateOf(TextFieldValue(song.artists.firstOrNull()?.name.orEmpty())) } if (showEditDialog) { TextFieldDialog( icon = { Icon( painter = painterResource(R.drawable.edit), contentDescription = null ) }, title = { Text(text = stringResource(R.string.edit_song)) }, textFields = listOf( stringResource(R.string.song_title) to titleField, stringResource(R.string.artist_name) to artistField ), onTextFieldsChange = { index, newValue -> if (index == 0) titleField = newValue else artistField = newValue }, onDoneMultiple = { values -> val newTitle = values[0] val newArtist = values[1] coroutineScope.launch { database.query { update(song.song.copy(title = newTitle)) val artist = song.artists.firstOrNull() if (artist != null) { update(artist.copy(name = newArtist)) } } showEditDialog = false onDismiss() } }, onDismiss = { showEditDialog = false } ) } 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) } var showDeleteUploadedDialog by rememberSaveable { mutableStateOf(false) } var isDeleting by remember { mutableStateOf(false) } if (showDeleteUploadedDialog) { DefaultDialog( onDismiss = { if (!isDeleting) showDeleteUploadedDialog = false }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, tint = MaterialTheme.colorScheme.error, ) }, title = { Text(stringResource(R.string.delete_uploaded_song)) }, buttons = { TextButton( onClick = { showDeleteUploadedDialog = false }, enabled = !isDeleting ) { Text(stringResource(R.string.cancel)) } TextButton( onClick = { val entityId = song.song.uploadEntityId if (entityId == null) { Toast.makeText( context, R.string.delete_uploaded_song_failed, Toast.LENGTH_SHORT ).show() showDeleteUploadedDialog = false return@TextButton } isDeleting = true coroutineScope.launch(Dispatchers.IO) { YouTube.deleteUploadedSong(entityId).onSuccess { database.query { delete(song.song) } withContext(Dispatchers.Main) { Toast.makeText( context, R.string.delete_uploaded_song_success, Toast.LENGTH_SHORT ).show() isDeleting = false showDeleteUploadedDialog = false onDismiss() } }.onFailure { withContext(Dispatchers.Main) { Toast.makeText( context, R.string.delete_uploaded_song_failed, Toast.LENGTH_SHORT ).show() isDeleting = false showDeleteUploadedDialog = false } } } }, enabled = !isDeleting ) { if (isDeleting) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp ) } else { Text( text = stringResource(R.string.delete), color = MaterialTheme.colorScheme.error ) } } } ) { Text( text = stringResource(R.string.delete_uploaded_song_confirm), style = MaterialTheme.typography.bodyMedium ) } } if (showSelectArtistDialog) { ListDialog( onDismiss = { showSelectArtistDialog = false }, ) { items( items = song.artists.distinctBy { it.id }, 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 = { // For episodes, show saved state and toggle save for later val isEpisode = song.song.isEpisode val isFavorite = if (isEpisode) song.song.inLibrary != null else song.song.liked IconButton( onClick = { if (isEpisode) { // Episode: toggle save for later (same pattern as songs) val isCurrentlySaved = song.song.inLibrary != null database.query { update(song.song.copy( inLibrary = if (isCurrentlySaved) null else LocalDateTime.now(), isEpisode = true )) } coroutineScope.launch(Dispatchers.IO) { if (isCurrentlySaved) { val setVideoIdEntity = database.getSetVideoId(song.id) val setVideoId = setVideoIdEntity?.setVideoId if (setVideoId != null) { YouTube.removeEpisodeFromSavedEpisodes(song.id, setVideoId).onSuccess { Timber.d("[EPISODE_SAVE] Removed episode from Episodes for Later: ${song.id}") }.onFailure { e -> Timber.e(e, "[EPISODE_SAVE] Failed to remove episode: ${song.id}") withContext(Dispatchers.Main) { Toast.makeText(context, R.string.error_episode_remove, Toast.LENGTH_SHORT).show() } } } } else { YouTube.addEpisodeToSavedEpisodes(song.id).onSuccess { Timber.d("[EPISODE_SAVE] Saved episode to Episodes for Later: ${song.id}") }.onFailure { e -> Timber.e(e, "[EPISODE_SAVE] Failed to save episode: ${song.id}") withContext(Dispatchers.Main) { Toast.makeText(context, R.string.error_episode_save, Toast.LENGTH_SHORT).show() } } } } } else { // Regular song: toggle like val s = song.song.toggleLike() database.query { update(s) } syncUtils.likeSong(s) } }, ) { Icon( painter = painterResource(if (isFavorite) R.drawable.favorite else R.drawable.favorite_border), tint = if (isFavorite) MaterialTheme.colorScheme.error else LocalContentColor.current, contentDescription = null, ) } }, ) HorizontalDivider() Spacer(modifier = Modifier.height(12.dp)) val bottomSheetPageState = LocalBottomSheetPageState.current val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = listOf( NewAction( icon = { Icon( painter = painterResource(R.drawable.edit), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.edit), onClick = { showEditDialog = true } ), NewAction( icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.add_to_playlist), onClick = { showChoosePlaylistDialog = true } ), NewAction( icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.share), onClick = { 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)) } ) ), modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp) ) } item { Material3MenuGroup( items = listOfNotNull( if (listenTogetherManager != null && listenTogetherManager.isInRoom && !listenTogetherManager.isHost) { Material3MenuItemData( title = { Text(text = stringResource(R.string.suggest_to_host)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { val durationMs = if (song.song.duration > 0) song.song.duration.toLong() * 1000 else 180000L val trackInfo = com.metrolist.music.listentogether.TrackInfo( id = song.id, title = song.song.title, artist = orderedArtists.joinToString(", ") { it.name }, album = song.song.albumName, duration = durationMs, thumbnail = song.thumbnailUrl ) listenTogetherManager.suggestTrack(trackInfo) onDismiss() } ) } else null, if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.start_radio)) }, description = { Text(text = stringResource(R.string.start_radio_desc)) }, icon = { Icon( painter = painterResource(R.drawable.radio), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.playQueue(YouTubeQueue.radio(song.toMediaMetadata())) } ) } else null, if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.play_next)) }, description = { Text(text = stringResource(R.string.play_next_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_play), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.playNext(song.toMediaItem()) } ) } else null, if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_queue)) }, description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { onDismiss() playerConnection.addToQueue(song.toMediaItem()) } ) } else null ) ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = buildList { add( Material3MenuItemData( title = { Text( text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial" ) }, icon = { Icon( painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { if (isPinned) { database.speedDialDao.delete(song.id) } else { database.speedDialDao.insert( SpeedDialItem( id = song.id, title = song.song.title, subtitle = song.artists.joinToString(", ") { it.name }, thumbnailUrl = song.song.thumbnailUrl, type = "SONG", explicit = song.song.explicit ) ) } } onDismiss() } ) ) // For episodes, use "Save for later" / "Remove from saved" (Episodes for Later playlist) // For regular songs, use "Add to library" if (song.song.isEpisode) { val isEpisodeSaved = song.song.inLibrary != null add( Material3MenuItemData( title = { Text(text = stringResource( if (isEpisodeSaved) R.string.remove_episode_from_saved else R.string.save_episode_for_later )) }, description = { Text(text = stringResource(R.string.episodes_for_later)) }, icon = { Icon( painter = painterResource( if (isEpisodeSaved) R.drawable.library_add_check else R.drawable.library_add ), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { val shouldBeSaved = !isEpisodeSaved // Update local database first (optimistic update) database.query { update(song.song.copy( inLibrary = if (shouldBeSaved) LocalDateTime.now() else null, isEpisode = true )) } // Sync with YouTube (handles login check internally) val setVideoId = if (isEpisodeSaved) database.getSetVideoId(song.id)?.setVideoId else null syncUtils.saveEpisode(song.id, shouldBeSaved, setVideoId) } onDismiss() } ) ) } else { add( Material3MenuItemData( title = { Text( text = stringResource( if (song.song.inLibrary == null) R.string.add_to_library else R.string.remove_from_library ) ) }, description = { Text(text = stringResource(R.string.add_to_library_desc)) }, icon = { Icon( painter = painterResource( if (song.song.inLibrary == null) R.drawable.library_add else R.drawable.library_add_check ), contentDescription = null, ) }, onClick = { val currentSong = song.song val isInLibrary = currentSong.inLibrary != null val token = if (isInLibrary) currentSong.libraryRemoveToken else currentSong.libraryAddToken token?.let { coroutineScope.launch { YouTube.feedback(listOf(it)) } } database.query { update(song.song.toggleLibrary()) } } ) ) } if (event != null) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.remove_from_history)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { onDismiss() database.query { delete(event) } } ) ) } if (playlistSong != null) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.remove_from_playlist)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { 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() } ) ) } if (isFromCache) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.remove_from_cache)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { onDismiss() cacheViewModel.removeSongFromCache(song.id) } ) ) } // Delete uploaded song option if (song.song.isUploaded) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.delete_uploaded_song)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { showDeleteUploadedDialog = true } ) ) } } ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = listOf( when (download?.state) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download) ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null ) }, onClick = { DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.id, false, ) } ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp ) }, onClick = { DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.id, false, ) } ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, description = { Text(text = stringResource(R.string.download_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { 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 { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = buildList { // Don't show "View Artist" for podcast episodes if (!song.song.isEpisode) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.view_artist)) }, description = { Text(text = song.artists.joinToString { it.name }) }, icon = { Icon( painter = painterResource(R.drawable.artist), contentDescription = null, ) }, onClick = { if (song.artists.size == 1) { navController.navigate("artist/${song.artists[0].id}") onDismiss() } else { showSelectArtistDialog = true } } ) ) } if (song.song.albumId != null) { // Show "View Podcast" for episodes, "View Album" for songs val isPodcast = song.song.isEpisode add( Material3MenuItemData( title = { Text(text = stringResource(if (isPodcast) R.string.view_podcast else R.string.view_album)) }, description = { song.song.albumName?.let { Text(text = it) } }, icon = { Icon( painter = painterResource(if (isPodcast) R.drawable.mic else R.drawable.album), contentDescription = null, ) }, onClick = { onDismiss() if (isPodcast) { navController.navigate("online_podcast/${song.song.albumId}") } else { navController.navigate("album/${song.song.albumId}") } } ) ) } // Subscribe to podcast option for episodes song.song.albumId?.takeIf { song.song.isEpisode }?.let { podcastId -> add( Material3MenuItemData( title = { Text( text = stringResource( if (isPodcastSubscribed) R.string.subscribed else R.string.subscribe_to_podcast ) ) }, description = { song.song.albumName?.let { Text(text = it) } }, icon = { Icon( painter = painterResource( if (isPodcastSubscribed) R.drawable.library_add_check else R.drawable.library_add ), contentDescription = null, ) }, onClick = { Timber.d("[PODCAST_LIB] Toggling podcast save for: $podcastId") coroutineScope.launch(Dispatchers.IO) { val existingPodcast = podcastEntity val isCurrentlySaved = existingPodcast?.bookmarkedAt != null // Call the API to save/unsave on YTM YouTube.savePodcast(podcastId, !isCurrentlySaved).onSuccess { Timber.d("[PODCAST_LIB] savePodcast API success!") }.onFailure { e -> Timber.e(e, "[PODCAST_LIB] savePodcast API failed") } // Update local database if (existingPodcast != null) { Timber.d("[PODCAST_LIB] Updating existing podcast") database.query { update(existingPodcast.toggleBookmark()) } } else { Timber.d("[PODCAST_LIB] Creating new podcast entry") database.query { insert( PodcastEntity( id = podcastId, title = song.song.albumName ?: "Unknown Podcast", author = song.artists.firstOrNull()?.name, thumbnailUrl = song.song.thumbnailUrl, ).toggleBookmark() ) } } } onDismiss() } ) ) } add( Material3MenuItemData( title = { Text(text = stringResource(R.string.refetch)) }, description = { Text(text = stringResource(R.string.refetch_desc)) }, icon = { Icon( painter = painterResource(R.drawable.sync), contentDescription = null, modifier = Modifier.graphicsLayer(rotationZ = rotationAnimation), ) }, onClick = { 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()) } } } } } ) ) add( Material3MenuItemData( title = { Text(text = stringResource(R.string.details)) }, description = { Text(text = stringResource(R.string.details_desc)) }, icon = { Icon( painter = painterResource(R.drawable.info), contentDescription = null, ) }, onClick = { onDismiss() bottomSheetPageState.show { ShowMediaInfo(song.id) } } ) ) } ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeAlbumMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration 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.Spacer 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.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api 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.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.LocalConfiguration 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.metrolist.innertube.YouTube import com.metrolist.innertube.models.AlbumItem import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.YouTubeAlbumRadio import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.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 listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val album by database.albumWithSongs(albumItem.id).collectAsState(initial = null) val isPinned by database.speedDialDao.isPinned(albumItem.id).collectAsState(initial = false) 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( 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(notAddedList) { song -> SongListItem(song = song) } } } var showSelectArtistDialog by rememberSaveable { mutableStateOf(false) } if (showSelectArtistDialog) { ListDialog( onDismiss = { showSelectArtistDialog = false }, ) { items( items = album?.artists.orEmpty().distinctBy { it.id }, 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() Spacer(modifier = Modifier.height(12.dp)) val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = listOfNotNull( if (!isGuest) { NewAction( icon = { Icon( painter = painterResource(R.drawable.play), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.play), onClick = { onDismiss() album?.songs?.let { songs -> if (songs.isNotEmpty()) { playerConnection.playQueue(YouTubeAlbumRadio(albumItem.playlistId)) } } } ) NewAction( icon = { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.shuffle), onClick = { onDismiss() album?.songs?.let { songs -> if (songs.isNotEmpty()) { playerConnection.playQueue(YouTubeAlbumRadio(albumItem.playlistId)) } } } ) } else null, NewAction( icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.share), onClick = { onDismiss() val intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, albumItem.shareLink) } context.startActivity(Intent.createChooser(intent, null)) } ) ), modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), columns = if (isGuest) 1 else 3 ) } item { Material3MenuGroup( items = listOfNotNull( if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.play_next)) }, description = { Text(text = stringResource(R.string.play_next_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_play), contentDescription = null, ) }, onClick = { album ?.songs ?.map { it.toMediaItem() } ?.let(playerConnection::playNext) onDismiss() } ) } else null, if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_queue)) }, description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { album ?.songs ?.map { it.toMediaItem() } ?.let(playerConnection::addToQueue) onDismiss() } ) } else null, Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_playlist)) }, description = { Text(text = stringResource(R.string.add_to_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, ) }, onClick = { showChoosePlaylistDialog = true } ), Material3MenuItemData( title = { Text( text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial" ) }, icon = { Icon( painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { if (isPinned) { database.speedDialDao.delete(albumItem.id) } else { database.speedDialDao.insert(SpeedDialItem.fromYTItem(albumItem)) } } onDismiss() } ) ) ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = listOf( when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download) ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null ) }, onClick = { album?.songs?.forEach { song -> DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.id, false, ) } } ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp ) }, onClick = { album?.songs?.forEach { song -> DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.id, false, ) } } ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, description = { Text(text = stringResource(R.string.download_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { 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, ) } } ) } } ) ) } albumItem.artists?.let { artists -> item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = listOf( Material3MenuItemData( title = { Text(text = stringResource(R.string.view_artist)) }, description = { Text(text = artists.joinToString { it.name }) }, icon = { Icon( painter = painterResource(R.drawable.artist), contentDescription = null, ) }, onClick = { if (artists.size == 1) { navController.navigate("artist/${artists[0].id}") onDismiss() } else { showSelectArtistDialog = true } } ) ) ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeArtistMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.content.Intent import android.content.res.Configuration import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer 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.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon 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.Modifier import androidx.compose.ui.platform.LocalConfiguration 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 com.metrolist.innertube.models.ArtistItem import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.db.entities.ArtistEntity import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.YouTubeListItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import androidx.compose.runtime.rememberCoroutineScope @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) val listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val isPinned by database.speedDialDao.isPinned(artist.id).collectAsState(initial = false) val coroutineScope = rememberCoroutineScope() YouTubeListItem( item = artist, trailingContent = {}, ) HorizontalDivider() Spacer(modifier = Modifier.height(12.dp)) val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = buildList { if (!isGuest) { artist.radioEndpoint?.let { watchEndpoint -> add( NewAction( icon = { Icon( painter = painterResource(R.drawable.radio), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.start_radio), onClick = { playerConnection.playQueue(YouTubeQueue(watchEndpoint)) onDismiss() } ) ) } } add( NewAction( icon = { Icon( painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = if (isPinned) "Unpin" else "Pin", onClick = { coroutineScope.launch(Dispatchers.IO) { if (isPinned) { database.speedDialDao.delete(artist.id) } else { database.speedDialDao.insert(SpeedDialItem.fromYTItem(artist)) } } onDismiss() } ) ) add( NewAction( icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.share), onClick = { val intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, artist.shareLink) } context.startActivity(Intent.createChooser(intent, null)) onDismiss() } ) ) }, modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), columns = if (isGuest) 1 else 3 ) } item { Material3MenuGroup( items = listOf( Material3MenuItemData( title = { Text(text = if (libraryArtist?.artist?.bookmarkedAt != null) stringResource(R.string.subscribed) else stringResource(R.string.subscribe)) }, icon = { Icon( painter = painterResource( if (libraryArtist?.artist?.bookmarkedAt != null) { R.drawable.subscribed } else { R.drawable.subscribe } ), contentDescription = null, ) }, onClick = { 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() ) } } } ) ) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubePlaylistMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration import android.widget.Toast 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.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues 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.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api 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.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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalConfiguration 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.core.net.toUri import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadService import coil3.compose.AsyncImage import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.utils.completed import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.PlaylistSongMap import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.MediaMetadata import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.utils.resize import com.metrolist.music.utils.exportYouTubePlaylistAsCSV import com.metrolist.music.utils.exportYouTubePlaylistAsM3U import com.metrolist.music.utils.getExportFileUri import com.metrolist.music.utils.joinByBullet import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.saveToPublicDocuments import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first 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, ) { val context = LocalContext.current val database = LocalDatabase.current val downloadUtil = LocalDownloadUtil.current val playerConnection = LocalPlayerConnection.current ?: return val listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val dbPlaylist by database.playlistByBrowseId(playlist.id).collectAsState(initial = null) val isPinned by database.speedDialDao.isPinned(playlist.id).collectAsState(initial = false) var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) } var showImportPlaylistDialog by rememberSaveable { mutableStateOf(false) } 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.withTransaction { 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 = { val isCurrentlySaved = dbPlaylist?.playlist?.bookmarkedAt != null if (dbPlaylist?.playlist == null) { database.transaction { val playlistEntity = PlaylistEntity( name = playlist.title, browseId = playlist.id, thumbnailUrl = playlist.thumbnail, isEditable = playlist.isEditable, 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) } } else { database.transaction { val currentPlaylist = dbPlaylist!!.playlist update(currentPlaylist, playlist) update(currentPlaylist.toggleLike()) } } coroutineScope.launch(Dispatchers.IO) { if (!isCurrentlySaved) { val playlistEntity = database.playlistByBrowseId(playlist.id).first()?.playlist if (playlistEntity != null) { songs .ifEmpty { YouTube .playlist(playlist.id) .completed() .getOrNull() ?.songs .orEmpty() }.map { it.toMediaMetadata() } .onEach { database.transaction { insert(it) } } .mapIndexed { index, song -> PlaylistSongMap( songId = song.id, playlistId = playlistEntity.id, position = index, setVideoId = song.setVideoId, ) }.forEach { database.transaction { insert(it) } } } } if (playlist.isPodcast) { YouTube .savePodcast(playlist.id, !isCurrentlySaved) .onSuccess { timber.log.Timber.d("[PODCAST_SAVE] savePodcast API success for ${playlist.id}") }.onFailure { e -> timber.log.Timber.e(e, "[PODCAST_SAVE] savePodcast API failed for ${playlist.id}") withContext(Dispatchers.Main) { android.widget.Toast .makeText( context, if (isCurrentlySaved) R.string.error_podcast_unsubscribe else R.string.error_podcast_subscribe, android.widget.Toast.LENGTH_SHORT, ).show() } } } } }, ) { 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 { mutableIntStateOf(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) } var showExportDialog 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)) } }, ) } ImportPlaylistDialog( isVisible = showImportPlaylistDialog, onGetSong = { val allSongs = songs .ifEmpty { YouTube .playlist(playlist.id) .completed() .getOrNull() ?.songs .orEmpty() }.map { it.toMediaMetadata() } database.withTransaction { allSongs.forEach(::insert) } allSongs.map { it.id } }, playlistTitle = playlist.title, onDismiss = { showImportPlaylistDialog = 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(notAddedList) { song -> ListItem( headlineContent = { Text(text = song.title) }, leadingContent = { Box( contentAlignment = Alignment.Center, modifier = Modifier.size(ListThumbnailSize), ) { AsyncImage( model = song.thumbnailUrl, contentDescription = null, modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(ThumbnailCornerRadius)), ) } }, supportingContent = { Text( text = joinByBullet( song.artists.joinToString { it.name }, makeTimeString(song.duration * 1000L), ), ) }, ) } } } val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = buildList { if (!isGuest) { playlist.playEndpoint?.let { playEndpoint -> add( NewAction( icon = { Icon( painter = painterResource(R.drawable.play), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.play), onClick = { playerConnection.playQueue(YouTubeQueue(playEndpoint)) onDismiss() }, ), ) } playlist.shuffleEndpoint?.let { shuffleEndpoint -> add( NewAction( icon = { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.shuffle), onClick = { playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) onDismiss() }, ), ) } playlist.radioEndpoint?.let { radioEndpoint -> add( NewAction( icon = { Icon( painter = painterResource(R.drawable.radio), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, text = stringResource(R.string.start_radio), onClick = { playerConnection.playQueue(YouTubeQueue(radioEndpoint)) onDismiss() }, ), ) } } }, modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp), ) } item { Material3MenuGroup( items = listOfNotNull( if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.play_next)) }, description = { Text(text = stringResource(R.string.play_next_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_play), contentDescription = null, ) }, onClick = { coroutineScope.launch { songs .ifEmpty { withContext(Dispatchers.IO) { YouTube .playlist(playlist.id) .completed() .getOrNull() ?.songs .orEmpty() } }.let { songs -> playerConnection.playNext( songs.map { it .copy(thumbnail = it.thumbnail.resize(544, 544)) .toMediaItem() }, ) } } onDismiss() }, ) } else { null }, if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_queue)) }, description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { coroutineScope.launch { songs .ifEmpty { withContext(Dispatchers.IO) { YouTube .playlist(playlist.id) .completed() .getOrNull() ?.songs .orEmpty() } }.let { songs -> playerConnection.addToQueue(songs.map { it.toMediaItem() }) } } onDismiss() }, ) } else { null }, Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_playlist)) }, description = { Text(text = stringResource(R.string.add_to_playlist_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, ) }, onClick = { showChoosePlaylistDialog = true }, ), Material3MenuItemData( title = { Text( text = if (isPinned) "Unpin from Speed dial" else "Pin to Speed dial", ) }, icon = { Icon( painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { if (isPinned) { database.speedDialDao.delete(playlist.id) } else { database.speedDialDao.insert(SpeedDialItem.fromYTItem(playlist)) } } onDismiss() }, ), ), ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = buildList { if (songs.isNotEmpty()) { add( when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download), ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, ) }, onClick = { showRemoveDownloadDialog = true }, ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { showRemoveDownloadDialog = true }, ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, description = { Text(text = stringResource(R.string.download_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { 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, ) } }, ) } }, ) } add( Material3MenuItemData( title = { Text(text = stringResource(R.string.export_playlist)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, onClick = { showExportDialog = true }, ), ) add( Material3MenuItemData( title = { Text(text = stringResource(R.string.share)) }, description = { Text(text = stringResource(R.string.share_desc)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, onClick = { 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) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.select)) }, icon = { Icon( painter = painterResource(R.drawable.select_all), contentDescription = null, ) }, onClick = { onDismiss() selectAction() }, ), ) } }, ) } } if (showExportDialog) { ExportDialog( onDismiss = { showExportDialog = false }, onShare = { format -> coroutineScope.launch { val ytSongs = if (songs.isEmpty()) { withContext(Dispatchers.IO) { YouTube .playlist(playlist.id) .completed() .getOrNull() ?.songs .orEmpty() } } else { songs } val result = when (format) { "csv" -> exportYouTubePlaylistAsCSV(context, playlist.title, ytSongs) "m3u" -> exportYouTubePlaylistAsM3U(context, playlist.title, ytSongs) else -> Result.failure(IllegalArgumentException("Unknown format")) } result .onSuccess { file -> val uri = getExportFileUri(context, file) val mime = if (format == "csv") "text/csv" else "audio/x-mpegurl" shareFile(context, uri, mime) }.onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } } onDismiss() }, onSave = { format -> coroutineScope.launch { val ytSongs = if (songs.isEmpty()) { withContext(Dispatchers.IO) { YouTube .playlist(playlist.id) .completed() .getOrNull() ?.songs .orEmpty() } } else { songs } val export = when (format) { "csv" -> exportYouTubePlaylistAsCSV(context, playlist.title, ytSongs) "m3u" -> exportYouTubePlaylistAsM3U(context, playlist.title, ytSongs) else -> Result.failure(IllegalArgumentException("Unknown format")) } export .onSuccess { file -> val mime = if (format == "csv") "text/csv" else "audio/x-mpegurl" val save = saveToPublicDocuments(context, file, mime) save .onSuccess { Toast.makeText(context, R.string.export_success, Toast.LENGTH_SHORT).show() } .onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } }.onFailure { Toast.makeText(context, R.string.export_failed, Toast.LENGTH_SHORT).show() } } onDismiss() }, ) } } private fun shareFile( context: android.content.Context, uri: android.net.Uri, mimeType: String, ) { val shareIntent = Intent(Intent.ACTION_SEND).apply { type = mimeType putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.export_playlist))) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSelectionSongMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu 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.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon 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.runtime.toMutableStateList 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.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.metrolist.innertube.YouTube import com.metrolist.innertube.models.SongItem import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.LocalSyncUtils import com.metrolist.music.R import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import kotlinx.coroutines.launch import java.time.LocalDateTime @Composable fun YouTubeSelectionSongMenu( songSelection: List, onDismiss: () -> Unit, clearAction: () -> Unit, ) { val context = LocalContext.current val database = LocalDatabase.current val downloadUtil = LocalDownloadUtil.current val playerConnection = LocalPlayerConnection.current ?: return val coroutineScope = rememberCoroutineScope() val syncUtils = LocalSyncUtils.current var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) } val listenTogetherManager = com.metrolist.music.LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && listenTogetherManager.isHost == false var downloadState by remember { mutableIntStateOf(Download.STATE_STOPPED) } var showRemoveDownloadDialog by remember { mutableStateOf(false) } // Check if all songs are liked val allLiked by remember(songSelection) { mutableStateOf( songSelection.isNotEmpty() && songSelection.all { song -> // Convert to MediaMetadata to check liked status val metadata = song.toMediaMetadata() metadata.liked }, ) } // Check if all songs are in library val allInLibrary by remember(songSelection) { mutableStateOf( songSelection.all { song -> val metadata = song.toMediaMetadata() metadata.inLibrary != null }, ) } 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 } } } AddToPlaylistDialogOnline( isVisible = showChoosePlaylistDialog, songs = remember { songSelection .map { song -> // Convert SongItem to Song entity val metadata = song.toMediaMetadata() com.metrolist.music.db.entities.Song( song = com.metrolist.music.db.entities.SongEntity( id = metadata.id, title = metadata.title, duration = metadata.duration, thumbnailUrl = metadata.thumbnailUrl, albumId = metadata.album?.id, albumName = metadata.album?.title, liked = metadata.liked, totalPlayTime = 0, inLibrary = metadata.inLibrary, isLocal = false, libraryAddToken = metadata.libraryAddToken, libraryRemoveToken = metadata.libraryRemoveToken, ), artists = metadata.artists.map { artist -> com.metrolist.music.db.entities.ArtistEntity( id = artist.id ?: "", name = artist.name, ) }, album = metadata.album?.let { album -> com.metrolist.music.db.entities.AlbumEntity( id = album.id, title = album.title, thumbnailUrl = metadata.thumbnailUrl, // Use song's thumbnail as album thumbnail songCount = 0, duration = 0, ) }, ) }.toMutableStateList() }, onProgressStart = { }, onPercentageChange = { }, onDismiss = { showChoosePlaylistDialog = 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)) } }, ) } val queueAllSongsStr = stringResource(R.string.queue_all_songs) LazyColumn( contentPadding = PaddingValues( start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { Material3MenuGroup( listOfNotNull( if (!isGuest) { Material3MenuItemData( icon = { Icon(painterResource(R.drawable.play), null) }, title = { Text(stringResource(R.string.play)) }, onClick = { playerConnection.playQueue( ListQueue( title = queueAllSongsStr, items = songSelection.map { it.toMediaItem() }, ), ) clearAction() onDismiss() }, ) } else { null }, if (!isGuest) { Material3MenuItemData( icon = { Icon(painterResource(R.drawable.shuffle), null) }, title = { Text(stringResource(R.string.shuffle)) }, onClick = { playerConnection.playQueue( ListQueue( title = queueAllSongsStr, items = songSelection.shuffled().map { it.toMediaItem() }, ), ) clearAction() onDismiss() }, ) } else { null }, if (!isGuest) { Material3MenuItemData( icon = { Icon(painterResource(R.drawable.queue_music), null) }, title = { Text(stringResource(R.string.add_to_queue)) }, onClick = { playerConnection.addToQueue(songSelection.map { it.toMediaItem() }) clearAction() onDismiss() }, ) } else { null }, Material3MenuItemData( icon = { Icon(painterResource(R.drawable.playlist_add), null) }, title = { Text(stringResource(R.string.add_to_playlist)) }, onClick = { showChoosePlaylistDialog = true }, ), Material3MenuItemData( title = { Text( text = stringResource( if (allInLibrary) R.string.remove_from_library else R.string.add_to_library, ), ) }, icon = { Icon( painter = painterResource( if (allInLibrary) R.drawable.library_add_check else R.drawable.library_add, ), contentDescription = null, ) }, onClick = { if (allInLibrary) { database.query { songSelection.forEach { song -> inLibrary(song.id, null) } } coroutineScope.launch { // Use the new reliable method that fetches fresh tokens songSelection.forEach { song -> YouTube.toggleSongLibrary(song.id, false) } } } else { database.transaction { songSelection.forEach { song -> insert(song.toMediaMetadata()) inLibrary(song.id, LocalDateTime.now()) } } coroutineScope.launch { // Use the new reliable method that fetches fresh tokens songSelection .filter { song -> song.toMediaMetadata().inLibrary == null }.forEach { song -> YouTube.toggleSongLibrary(song.id, true) } } } clearAction() onDismiss() }, ), when (downloadState) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download), ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null, ) }, onClick = { showRemoveDownloadDialog = true }, ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) }, onClick = { showRemoveDownloadDialog = true }, ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { 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, ) } clearAction() onDismiss() }, ) } }, Material3MenuItemData( title = { Text( text = stringResource( if (allLiked) R.string.dislike_all else R.string.like_all, ), ) }, icon = { Icon( painter = painterResource( if (allLiked) R.drawable.favorite else R.drawable.favorite_border, ), contentDescription = null, ) }, onClick = { database.transaction { songSelection.forEach { song -> val metadata = song.toMediaMetadata() if ((!allLiked && !metadata.liked) || allLiked) { // Insert the song first if it doesn't exist insert(metadata) // Create SongEntity with toggled like status val songEntity = com.metrolist.music.db.entities.SongEntity( id = metadata.id, title = metadata.title, duration = metadata.duration, thumbnailUrl = metadata.thumbnailUrl, albumId = metadata.album?.id, albumName = metadata.album?.title, liked = !metadata.liked, totalPlayTime = 0, inLibrary = metadata.inLibrary, isLocal = false, libraryAddToken = metadata.libraryAddToken, libraryRemoveToken = metadata.libraryRemoveToken, ) update(songEntity) syncUtils.likeSong(songEntity) } } } clearAction() onDismiss() }, ), ), ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/menu/YouTubeSongMenu.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.menu import android.annotation.SuppressLint import android.content.Intent import android.content.res.Configuration import androidx.compose.foundation.basicMarquee 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.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.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.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.LocalConfiguration 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 coil3.compose.AsyncImage import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.SongItem import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.LocalSyncUtils import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.db.entities.SongEntity import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.MediaMetadata import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.ListDialog import com.metrolist.music.ui.component.LocalBottomSheetPageState import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.NewAction import com.metrolist.music.ui.component.NewActionGrid import com.metrolist.music.ui.utils.ShowMediaInfo import com.metrolist.music.ui.utils.resize import com.metrolist.music.utils.joinByBullet import com.metrolist.music.utils.makeTimeString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDateTime @SuppressLint("MutableCollectionMutableState") @Composable fun YouTubeSongMenu( song: SongItem, navController: NavController, onDismiss: () -> Unit, onHistoryRemoved: () -> 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 syncUtils = LocalSyncUtils.current val listenTogetherManager = LocalListenTogetherManager.current val isPinned by database.speedDialDao.isPinned(song.id).collectAsState(initial = false) val artists = remember { song.artists.mapNotNull { it.id?.let { artistId -> MediaMetadata.Artist(id = artistId, name = it.name) } } } var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) } AddToPlaylistDialog( isVisible = showChoosePlaylistDialog, onGetSong = { playlist -> database.withTransaction { 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( headlineContent = { Text( text = song.title, modifier = Modifier.basicMarquee(), maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, supportingContent = { Text( text = joinByBullet( song.artists.joinToString { it.name }, song.duration?.let { makeTimeString(it * 1000L) }, ) ) }, leadingContent = { Box( contentAlignment = Alignment.Center, modifier = Modifier .size(ListThumbnailSize) .clip(RoundedCornerShape(ThumbnailCornerRadius)) ) { AsyncImage( model = song.thumbnail, contentDescription = null, modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(ThumbnailCornerRadius)) ) } }, trailingContent = { // For episodes, show saved state and toggle save for later val isEpisode = song.isEpisode val isFavorite = if (isEpisode) librarySong?.song?.inLibrary != null else librarySong?.song?.liked == true IconButton( onClick = { if (isEpisode) { // Episode: toggle save for later val currentLibrarySong = librarySong val isCurrentlySaved = currentLibrarySong?.song?.inLibrary != null val shouldBeSaved = !isCurrentlySaved // Update local database first (optimistic update) database.query { if (currentLibrarySong != null) { update(currentLibrarySong.song.copy(inLibrary = if (shouldBeSaved) LocalDateTime.now() else null)) } else { insert(song.toMediaMetadata().toSongEntity().copy(inLibrary = LocalDateTime.now(), isEpisode = true)) } } // Sync with YouTube (handles login check internally) coroutineScope.launch(Dispatchers.IO) { val setVideoId = if (isCurrentlySaved) song.setVideoId ?: database.getSetVideoId(song.id)?.setVideoId else null syncUtils.saveEpisode(song.id, shouldBeSaved, setVideoId) } } else { // Regular song: toggle like database.transaction { librarySong.let { librarySong -> val s: SongEntity if (librarySong == null) { insert(song.toMediaMetadata(), SongEntity::toggleLike) s = song.toMediaMetadata().toSongEntity().let(SongEntity::toggleLike) } else { s = librarySong.song.toggleLike() update(s) } syncUtils.likeSong(s) } } } }, ) { Icon( painter = painterResource(if (isFavorite) R.drawable.favorite else R.drawable.favorite_border), tint = if (isFavorite) MaterialTheme.colorScheme.error else LocalContentColor.current, contentDescription = null, ) } }, ) HorizontalDivider() Spacer(modifier = Modifier.height(12.dp)) val bottomSheetPageState = LocalBottomSheetPageState.current val configuration = LocalConfiguration.current val isPortrait = configuration.orientation == Configuration.ORIENTATION_PORTRAIT val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost LazyColumn( contentPadding = PaddingValues( start = 0.dp, top = 0.dp, end = 0.dp, bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), ), ) { item { NewActionGrid( actions = listOfNotNull( if (!isGuest) { NewAction( icon = { Icon( painter = painterResource(R.drawable.playlist_play), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.play_next), onClick = { playerConnection.playNext(song.copy(thumbnail = song.thumbnail.resize(544,544)).toMediaItem()) onDismiss() } ) } else null, NewAction( icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.add_to_playlist), onClick = { showChoosePlaylistDialog = true } ), NewAction( icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) }, text = stringResource(R.string.share), onClick = { val intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, song.shareLink) } context.startActivity(Intent.createChooser(intent, null)) onDismiss() } ) ), columns = if (isGuest) 2 else 3, modifier = Modifier.padding(horizontal = 4.dp, vertical = 16.dp) ) } item { Material3MenuGroup( items = listOfNotNull( if (listenTogetherManager != null && listenTogetherManager.isInRoom && !listenTogetherManager.isHost) { Material3MenuItemData( title = { Text(text = stringResource(R.string.suggest_to_host)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { val durationMs = if (song.duration != null && song.duration!! > 0) song.duration!! * 1000L else 180000L val trackInfo = com.metrolist.music.listentogether.TrackInfo( id = song.id, title = song.title, artist = artists.joinToString(", ") { it.name }, album = song.album?.name, duration = durationMs, thumbnail = song.thumbnail ) listenTogetherManager.suggestTrack(trackInfo) onDismiss() } ) } else null, if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.start_radio)) }, description = { Text(text = stringResource(R.string.start_radio_desc)) }, icon = { Icon( painter = painterResource(R.drawable.radio), contentDescription = null, ) }, onClick = { playerConnection.playQueue(YouTubeQueue.radio(song.toMediaMetadata())) onDismiss() } ) } else null, if (!isGuest) { Material3MenuItemData( title = { Text(text = stringResource(R.string.add_to_queue)) }, description = { Text(text = stringResource(R.string.add_to_queue_desc)) }, icon = { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, ) }, onClick = { playerConnection.addToQueue(song.toMediaItem()) onDismiss() } ) } else null ) ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = buildList { // Save/Remove for Later option for podcast episodes if (song.isEpisode) { if (song.setVideoId != null) { // Episode is saved - show remove option add( Material3MenuItemData( title = { Text(text = stringResource(R.string.remove_episode_from_saved)) }, icon = { Icon( painter = painterResource(R.drawable.remove), contentDescription = null, ) }, onClick = { // Update local database first (optimistic update) database.query { librarySong?.song?.let { update(it.copy(inLibrary = null)) } } // Sync with YouTube (handles login check internally) syncUtils.saveEpisode(song.id, false, song.setVideoId) onDismiss() } ) ) } else { // Episode not saved - show save option add( Material3MenuItemData( title = { Text(text = stringResource(R.string.save_episode_for_later)) }, description = { Text(text = stringResource(R.string.save_episode_for_later_desc)) }, icon = { Icon( painter = painterResource(R.drawable.playlist_add), contentDescription = null, ) }, onClick = { // Update local database first (optimistic update) database.query { if (librarySong != null) { update(librarySong!!.song.copy(inLibrary = java.time.LocalDateTime.now())) } else { insert(song.toMediaMetadata().toSongEntity().copy(inLibrary = java.time.LocalDateTime.now(), isEpisode = true)) } } // Sync with YouTube (handles login check internally) syncUtils.saveEpisode(song.id, true, null) onDismiss() } ) ) } } if (song.historyRemoveToken != null) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.remove_from_history)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { coroutineScope.launch { Timber.d("[HISTORY_REMOVE] Removing song ${song.id} from YTM history") YouTube.feedback(listOf(song.historyRemoveToken!!)) .onSuccess { Timber.d("[HISTORY_REMOVE] Successfully removed from YTM history") } .onFailure { e -> Timber.e(e, "[HISTORY_REMOVE] Failed to remove from YTM history") } delay(500) onHistoryRemoved() onDismiss() } } ) ) } add( Material3MenuItemData( title = { Text( text = if (isPinned) stringResource(R.string.unpin_from_speed_dial) else stringResource(R.string.pin_to_speed_dial) ) }, icon = { Icon( painter = painterResource(if (isPinned) R.drawable.remove else R.drawable.add), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { if (isPinned) { database.speedDialDao.delete(song.id) } else { database.speedDialDao.insert(SpeedDialItem.fromYTItem(song)) } } onDismiss() } ) ) add( Material3MenuItemData( title = { Text(text = if (librarySong?.song?.inLibrary != null) stringResource(R.string.remove_from_library) else stringResource(R.string.add_to_library)) }, description = { Text(text = stringResource(R.string.add_to_library_desc)) }, icon = { Icon( painter = painterResource(if (librarySong?.song?.inLibrary != null) R.drawable.library_add_check else R.drawable.library_add), contentDescription = null, ) }, onClick = { val isInLibrary = librarySong?.song?.inLibrary != null // Use the new reliable method that fetches fresh tokens coroutineScope.launch { YouTube.toggleSongLibrary(song.id, !isInLibrary) } if (isInLibrary) { database.query { inLibrary(song.id, null) } } else { database.transaction { insert(song.toMediaMetadata()) inLibrary(song.id, LocalDateTime.now()) addLibraryTokens( song.id, song.libraryAddToken, song.libraryRemoveToken ) } } } ) ) } ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { Material3MenuGroup( items = listOf( when (download?.state) { Download.STATE_COMPLETED -> { Material3MenuItemData( title = { Text( text = stringResource(R.string.remove_download) ) }, icon = { Icon( painter = painterResource(R.drawable.offline), contentDescription = null ) }, onClick = { DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.id, false, ) } ) } Download.STATE_QUEUED, Download.STATE_DOWNLOADING -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.downloading)) }, icon = { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp ) }, onClick = { DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.id, false, ) } ) } else -> { Material3MenuItemData( title = { Text(text = stringResource(R.string.action_download)) }, description = { Text(text = stringResource(R.string.download_desc)) }, icon = { Icon( painter = painterResource(R.drawable.download), contentDescription = null, ) }, onClick = { 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, ) } ) } } ) ) } item { Spacer(modifier = Modifier.height(12.dp)) } item { // Check if this is a podcast episode (album ID doesn't start with MPREb_) val isPodcast = song.album?.let { !it.id.startsWith("MPREb_") } ?: false Material3MenuGroup( items = buildList { // Don't show "View Artist" for podcasts - only show "View Podcast" if (artists.isNotEmpty() && !isPodcast) { add( Material3MenuItemData( title = { Text(text = stringResource(R.string.view_artist)) }, description = { Text(text = song.artists.joinToString { it.name }) }, icon = { Icon( painter = painterResource(R.drawable.artist), contentDescription = null, ) }, onClick = { if (artists.size == 1) { navController.navigate("artist/${artists[0].id}") onDismiss() } else { showSelectArtistDialog = true } } ) ) } song.album?.let { album -> add( Material3MenuItemData( title = { Text(text = stringResource(if (isPodcast) R.string.view_podcast else R.string.view_album)) }, description = { Text(text = album.name) }, icon = { Icon( painter = painterResource(if (isPodcast) R.drawable.mic else R.drawable.album), contentDescription = null, ) }, onClick = { if (isPodcast) { navController.navigate("online_podcast/${album.id}") } else { navController.navigate("album/${album.id}") } onDismiss() } ) ) } add( Material3MenuItemData( title = { Text(text = stringResource(R.string.details)) }, description = { Text(text = stringResource(R.string.details_desc)) }, icon = { Icon( painter = painterResource(R.drawable.info), contentDescription = null, ) }, onClick = { onDismiss() bottomSheetPageState.show { ShowMediaInfo(song.id) } } ) ) } ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/player/MiniPlayer.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors * * Performance optimized MiniPlayer - prevents unnecessary recomposition */ package com.metrolist.music.ui.player import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring 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.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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape 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.MutableLongState import androidx.compose.runtime.Stable 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.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.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalWindowInfo 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.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 coil3.compose.AsyncImage import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.CropAlbumArtKey import com.metrolist.music.constants.DarkModeKey import com.metrolist.music.constants.MiniPlayerHeight import com.metrolist.music.constants.PureBlackMiniPlayerKey import com.metrolist.music.constants.SwipeSensitivityKey import com.metrolist.music.constants.SwipeThumbnailKey import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.constants.UseNewMiniPlayerDesignKey import com.metrolist.music.db.entities.ArtistEntity import com.metrolist.music.listentogether.ListenTogetherManager import com.metrolist.music.models.MediaMetadata import com.metrolist.music.playback.CastConnectionHandler import com.metrolist.music.playback.PlayerConnection import com.metrolist.music.ui.screens.settings.DarkMode import com.metrolist.music.ui.utils.resize import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.launch import kotlin.math.absoluteValue import kotlin.math.roundToInt import com.metrolist.music.ui.component.Icon as MIcon /** * Stable wrapper for progress state - reads values only during draw phase * This prevents recomposition when position/duration change */ @Stable class ProgressState( private val positionState: MutableLongState, private val durationState: MutableLongState, ) { val progress: Float get() { val duration = durationState.longValue return if (duration > 0) (positionState.longValue.toFloat() / duration).coerceIn(0f, 1f) else 0f } } @Composable fun MiniPlayer( positionState: MutableLongState, durationState: MutableLongState, modifier: Modifier = Modifier, ) { val useNewMiniPlayerDesign by rememberPreference(UseNewMiniPlayerDesignKey, true) // Create stable progress state - doesn't cause recomposition on position changes val progressState = remember { ProgressState(positionState, durationState) } if (useNewMiniPlayerDesign) { NewMiniPlayer( progressState = progressState, modifier = modifier, ) } else { Box(modifier = modifier.fillMaxWidth()) { LegacyMiniPlayer( progressState = progressState, modifier = Modifier.align(Alignment.Center), ) } } } // ============================================================================ // NEW MINI PLAYER DESIGN // ============================================================================ @Composable private fun NewMiniPlayer( progressState: ProgressState, modifier: Modifier = Modifier, ) { val playerConnection = LocalPlayerConnection.current ?: return // Theme settings - these rarely change val pureBlack by rememberPreference(PureBlackMiniPlayerKey, defaultValue = false) val isSystemInDarkTheme = isSystemInDarkTheme() val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON } // Player states - only collect what's needed at this level val playbackState by playerConnection.playbackState.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val canSkipNext by playerConnection.canSkipNext.collectAsState() val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() // Cast state - safely access castConnectionHandler to prevent crashes during service lifecycle changes val castHandler = remember(playerConnection) { try { playerConnection.service.castConnectionHandler } catch (e: Exception) { null } } val isCasting by castHandler?.isCasting?.collectAsState() ?: remember { mutableStateOf(false) } // Swipe settings val swipeSensitivity by rememberPreference(SwipeSensitivityKey, 0.73f) val swipeThumbnailPref by rememberPreference(SwipeThumbnailKey, true) // Disable swipe for Listen Together guests val listenTogetherManager = LocalListenTogetherManager.current val isListenTogetherGuest = listenTogetherManager?.let { it.isInRoom && !it.isHost } ?: false val swipeThumbnail = swipeThumbnailPref && !isListenTogetherGuest val layoutDirection = LocalLayoutDirection.current val coroutineScope = rememberCoroutineScope() val windowInfo = LocalWindowInfo.current val configuration = LocalConfiguration.current val density = LocalDensity.current val isTabletLandscape = remember(windowInfo.containerSize.width, configuration.orientation) { (windowInfo.containerSize.width / density.density) >= 600f && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } // Swipe animation state val offsetXAnimatable = remember { Animatable(0f) } var dragStartTime by remember { mutableLongStateOf(0L) } var totalDragDistance by remember { mutableFloatStateOf(0f) } val animationSpec = remember { spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow) } val autoSwipeThreshold = remember(swipeSensitivity) { (600 / (1f + kotlin.math.exp(-(-11.44748 * swipeSensitivity + 9.04945)))).roundToInt() } // Memoize colors val backgroundColor = if (pureBlack && useDarkTheme) Color.Black else MaterialTheme.colorScheme.surfaceContainer val primaryColor = MaterialTheme.colorScheme.primary val outlineColor = MaterialTheme.colorScheme.outline val onSurfaceColor = MaterialTheme.colorScheme.onSurface val errorColor = MaterialTheme.colorScheme.error Box( modifier = modifier .fillMaxWidth() .height(MiniPlayerHeight) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(horizontal = 12.dp) .let { baseModifier -> if (swipeThumbnail) { baseModifier.pointerInput(Unit) { detectHorizontalDragGestures( onDragStart = { dragStartTime = System.currentTimeMillis() totalDragDistance = 0f }, onDragCancel = { coroutineScope.launch { offsetXAnimatable.animateTo(0f, animationSpec) } }, onHorizontalDrag = { _, dragAmount -> val adjustedDragAmount = if (layoutDirection == LayoutDirection.Rtl) -dragAmount else dragAmount val canSkipPrevious = playerConnection.player.previousMediaItemIndex != -1 val canSkipNext = playerConnection.player.nextMediaItemIndex != -1 val tryingToSwipeRight = adjustedDragAmount > 0 val tryingToSwipeLeft = adjustedDragAmount < 0 val allowLeft = tryingToSwipeLeft && canSkipNext val allowRight = tryingToSwipeRight && canSkipPrevious val canReturnToCenter = (tryingToSwipeRight && !canSkipPrevious && offsetXAnimatable.value < 0) || (tryingToSwipeLeft && !canSkipNext && offsetXAnimatable.value > 0) if (allowLeft || allowRight || canReturnToCenter) { totalDragDistance += kotlin.math.abs(adjustedDragAmount) 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 = (swipeSensitivity * -8.25f) + 8.5f val shouldChangeSong = (kotlin.math.abs(currentOffset) > minDistanceThreshold && velocity > velocityThreshold) || (kotlin.math.abs(currentOffset) > autoSwipeThreshold) if (shouldChangeSong) { if (currentOffset > 0 && canSkipPrevious) { playerConnection.player.seekToPreviousMediaItem() } else if (currentOffset <= 0 && canSkipNext) { playerConnection.player.seekToNext() } } coroutineScope.launch { offsetXAnimatable.animateTo(0f, animationSpec) } }, ) } } else { baseModifier } }, ) { Box( modifier = Modifier .then(if (isTabletLandscape) Modifier.width(500.dp).align(Alignment.Center) else Modifier.fillMaxWidth()) .height(64.dp) .offset { IntOffset(offsetXAnimatable.value.roundToInt(), 0) } .clip(RoundedCornerShape(32.dp)) .background(color = backgroundColor) .border(1.dp, outlineColor.copy(alpha = 0.3f), RoundedCornerShape(32.dp)), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 8.dp), ) { // Play button with progress - isolated composable NewMiniPlayerPlayButton( progressState = progressState, playbackState = playbackState, isCasting = isCasting, castHandler = castHandler, playerConnection = playerConnection, mediaMetadata = mediaMetadata, primaryColor = primaryColor, outlineColor = outlineColor, listenTogetherManager = listenTogetherManager, ) Spacer(modifier = Modifier.width(16.dp)) // Song info - isolated composable NewMiniPlayerSongInfo( mediaMetadata = mediaMetadata, onSurfaceColor = onSurfaceColor, errorColor = errorColor, modifier = Modifier.weight(1f), ) Spacer(modifier = Modifier.width(12.dp)) // Cast indicator if (isCasting) { Icon( painter = painterResource(R.drawable.cast_connected), contentDescription = "Casting", tint = primaryColor, modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(12.dp)) } // Subscribe button - isolated composable mediaMetadata?.artists?.firstOrNull()?.id?.let { artistId -> SubscribeButton(artistId = artistId, metadata = mediaMetadata!!) } Spacer(modifier = Modifier.width(8.dp)) // Favorite button - isolated composable mediaMetadata?.let { FavoriteButton(songId = it.id) } } } } } /** * Play button with circular progress indicator * Uses drawWithContent to update progress without recomposition */ @Composable private fun NewMiniPlayerPlayButton( progressState: ProgressState, playbackState: Int, isCasting: Boolean, castHandler: CastConnectionHandler?, playerConnection: PlayerConnection, mediaMetadata: MediaMetadata?, primaryColor: Color, outlineColor: Color, listenTogetherManager: ListenTogetherManager?, ) { val isPlaying by playerConnection.isPlaying.collectAsState() val castIsPlaying by castHandler?.castIsPlaying?.collectAsState() ?: remember { mutableStateOf(false) } val effectiveIsPlaying = if (isCasting) castIsPlaying else isPlaying val isListenTogetherGuest = listenTogetherManager?.let { it.isInRoom && !it.isHost } ?: false val isMuted by playerConnection.isMuted.collectAsState() val trackColor = outlineColor.copy(alpha = 0.2f) val strokeWidth = 3.dp Box( contentAlignment = Alignment.Center, modifier = Modifier .size(48.dp) .drawWithContent { drawContent() // Draw progress arc - this reads progressState.progress during draw phase only val progress = progressState.progress val stroke = Stroke(width = strokeWidth.toPx(), cap = StrokeCap.Round) val startAngle = -90f val sweepAngle = 360f * progress val diameter = size.minDimension val topLeft = Offset((size.width - diameter) / 2, (size.height - diameter) / 2) // Draw track drawArc( color = trackColor, startAngle = 0f, sweepAngle = 360f, useCenter = false, topLeft = topLeft, size = Size(diameter, diameter), style = stroke, ) // Draw progress drawArc( color = primaryColor, startAngle = startAngle, sweepAngle = sweepAngle, useCenter = false, topLeft = topLeft, size = Size(diameter, diameter), style = stroke, ) }, ) { // Thumbnail with play/pause overlay Box( contentAlignment = Alignment.Center, modifier = Modifier .size(40.dp) .clip(CircleShape) .border(1.dp, outlineColor.copy(alpha = 0.3f), CircleShape) .clickable { if (isListenTogetherGuest) { playerConnection.toggleMute() return@clickable } if (isCasting) { if (castIsPlaying) castHandler?.pause() else castHandler?.play() } else if (playbackState == Player.STATE_ENDED) { playerConnection.player.seekTo(0, 0) playerConnection.player.playWhenReady = true } else { playerConnection.togglePlayPause() } }, ) { mediaMetadata?.let { metadata -> val thumbnailUrl = remember(metadata.thumbnailUrl) { metadata.thumbnailUrl?.resize(120, 120) } AsyncImage( model = thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize().clip(CircleShape), ) } // Overlay for paused state or muted (guest) if (isListenTogetherGuest && isMuted || (!isListenTogetherGuest && (!effectiveIsPlaying || playbackState == Player.STATE_ENDED)) ) { Box( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.4f), CircleShape), ) Icon( painter = painterResource( if (isListenTogetherGuest) { if (isMuted) R.drawable.volume_off else R.drawable.volume_up } else if (playbackState == Player.STATE_ENDED) { R.drawable.replay } else { R.drawable.play }, ), contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp), ) } } } } /** * Song info display - title and artist */ @Composable private fun NewMiniPlayerSongInfo( mediaMetadata: MediaMetadata?, onSurfaceColor: Color, errorColor: Color, modifier: Modifier = Modifier, ) { val error by LocalPlayerConnection.current?.error?.collectAsState() ?: remember { mutableStateOf(null) } Column( modifier = modifier, verticalArrangement = Arrangement.Center, ) { mediaMetadata?.let { metadata -> Text( text = metadata.title, color = onSurfaceColor, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee(iterations = 1, initialDelayMillis = 3000, velocity = 30.dp), ) Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { if (metadata.explicit) MIcon.Explicit() if (metadata.artists.any { it.name.isNotBlank() }) { Text( text = metadata.artists.joinToString { it.name }, color = onSurfaceColor.copy(alpha = 0.7f), fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee(iterations = 1, initialDelayMillis = 3000, velocity = 30.dp), ) } } AnimatedVisibility(visible = error != null, enter = fadeIn(), exit = fadeOut()) { Text( text = stringResource(R.string.error_playing), color = errorColor, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } // ============================================================================ // LEGACY MINI PLAYER DESIGN // ============================================================================ @Composable private fun LegacyMiniPlayer( progressState: ProgressState, modifier: Modifier = Modifier, ) { val playerConnection = LocalPlayerConnection.current ?: return val pureBlack by rememberPreference(PureBlackMiniPlayerKey, defaultValue = false) val playbackState by playerConnection.playbackState.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val canSkipNext by playerConnection.canSkipNext.collectAsState() val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() val castHandler = remember(playerConnection) { try { playerConnection.service.castConnectionHandler } catch (e: Exception) { null } } val isCasting by castHandler?.isCasting?.collectAsState() ?: remember { mutableStateOf(false) } val swipeSensitivity by rememberPreference(SwipeSensitivityKey, 0.73f) val swipeThumbnailPref by rememberPreference(SwipeThumbnailKey, true) // Disable swipe for Listen Together guests val listenTogetherManager = LocalListenTogetherManager.current val isListenTogetherGuest = listenTogetherManager?.let { it.isInRoom && !it.isHost } ?: false val swipeThumbnail = swipeThumbnailPref && !isListenTogetherGuest val layoutDirection = LocalLayoutDirection.current val coroutineScope = rememberCoroutineScope() val windowInfo = LocalWindowInfo.current val configuration = LocalConfiguration.current val density = LocalDensity.current val isTabletLandscape = remember(windowInfo.containerSize.width, configuration.orientation) { (windowInfo.containerSize.width / density.density) >= 600f && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE } val offsetXAnimatable = remember { Animatable(0f) } var dragStartTime by remember { mutableLongStateOf(0L) } var totalDragDistance by remember { mutableFloatStateOf(0f) } val animationSpec = remember { spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow) } val autoSwipeThreshold = remember(swipeSensitivity) { (600 / (1f + kotlin.math.exp(-(-11.44748 * swipeSensitivity + 9.04945)))).roundToInt() } val primaryColor = MaterialTheme.colorScheme.primary val trackColor = MaterialTheme.colorScheme.surfaceVariant Box( modifier = modifier .then(if (isTabletLandscape) Modifier.width(500.dp) else Modifier.fillMaxWidth()) .height(MiniPlayerHeight) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) .background( if (pureBlack && isSystemInDarkTheme()) { Color.Black } else { MaterialTheme.colorScheme.surfaceContainer }, ).let { baseModifier -> if (swipeThumbnail) { baseModifier.pointerInput(Unit) { detectHorizontalDragGestures( onDragStart = { dragStartTime = System.currentTimeMillis() totalDragDistance = 0f }, onDragCancel = { coroutineScope.launch { offsetXAnimatable.animateTo(0f, animationSpec) } }, onHorizontalDrag = { _, dragAmount -> val adjustedDragAmount = if (layoutDirection == LayoutDirection.Rtl) -dragAmount else dragAmount val canSkipPrevious = playerConnection.player.previousMediaItemIndex != -1 val canSkipNext = playerConnection.player.nextMediaItemIndex != -1 val tryingToSwipeRight = adjustedDragAmount > 0 val tryingToSwipeLeft = adjustedDragAmount < 0 val allowLeft = tryingToSwipeLeft && canSkipNext val allowRight = tryingToSwipeRight && canSkipPrevious val canReturnToCenter = (tryingToSwipeRight && !canSkipPrevious && offsetXAnimatable.value < 0) || (tryingToSwipeLeft && !canSkipNext && offsetXAnimatable.value > 0) if (allowLeft || allowRight || canReturnToCenter) { totalDragDistance += kotlin.math.abs(adjustedDragAmount) 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 = (swipeSensitivity * -8.25f) + 8.5f val shouldChangeSong = (kotlin.math.abs(currentOffset) > minDistanceThreshold && velocity > velocityThreshold) || (kotlin.math.abs(currentOffset) > autoSwipeThreshold) if (shouldChangeSong) { if (currentOffset > 0 && canSkipPrevious) { playerConnection.player.seekToPreviousMediaItem() } else if (currentOffset <= 0 && canSkipNext) { playerConnection.player.seekToNext() } } coroutineScope.launch { offsetXAnimatable.animateTo(0f, animationSpec) } }, ) } } else { baseModifier } }, ) { // Progress bar - uses drawWithContent to avoid recomposition Box( modifier = Modifier .fillMaxWidth() .height(2.dp) .align(Alignment.BottomCenter) .drawWithContent { val progress = progressState.progress drawRect(trackColor) drawRect(primaryColor, size = Size(size.width * progress, size.height)) }, ) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxSize() .offset { IntOffset(offsetXAnimatable.value.roundToInt(), 0) } .padding(end = 12.dp), ) { Box(Modifier.weight(1f)) { mediaMetadata?.let { LegacyMiniMediaInfo( mediaMetadata = it, pureBlack = pureBlack, modifier = Modifier.padding(horizontal = 6.dp), ) } } LegacyPlayPauseButton( playbackState = playbackState, isCasting = isCasting, castHandler = castHandler, playerConnection = playerConnection, listenTogetherManager = listenTogetherManager, ) IconButton( enabled = canSkipNext && !isListenTogetherGuest, onClick = if (isListenTogetherGuest) ({}) else ({ playerConnection.seekToNext() }), ) { Icon(painter = painterResource(R.drawable.skip_next), contentDescription = null) } } // Swipe indicator if (offsetXAnimatable.value.absoluteValue > 50f) { Box( modifier = Modifier .align(if (offsetXAnimatable.value > 0) Alignment.CenterStart else Alignment.CenterEnd) .padding(horizontal = 16.dp), ) { Icon( painter = painterResource( if (offsetXAnimatable.value > 0) R.drawable.skip_previous else R.drawable.skip_next, ), contentDescription = null, tint = primaryColor.copy( alpha = (offsetXAnimatable.value.absoluteValue / autoSwipeThreshold).coerceIn(0f, 1f), ), modifier = Modifier.size(24.dp), ) } } } } @Composable private fun LegacyPlayPauseButton( playbackState: Int, isCasting: Boolean, castHandler: CastConnectionHandler?, playerConnection: PlayerConnection, listenTogetherManager: ListenTogetherManager?, ) { val isPlaying by playerConnection.isPlaying.collectAsState() val castIsPlaying by castHandler?.castIsPlaying?.collectAsState() ?: remember { mutableStateOf(false) } val effectiveIsPlaying = if (isCasting) castIsPlaying else isPlaying val isListenTogetherGuest = listenTogetherManager?.let { it.isInRoom && !it.isHost } ?: false val isMuted by playerConnection.isMuted.collectAsState() IconButton( onClick = { if (isListenTogetherGuest) { playerConnection.toggleMute() return@IconButton } if (isCasting) { if (castIsPlaying) castHandler?.pause() else castHandler?.play() } else if (playbackState == Player.STATE_ENDED) { playerConnection.player.seekTo(0, 0) playerConnection.player.playWhenReady = true } else { playerConnection.togglePlayPause() } }, ) { Icon( painter = painterResource( when { isListenTogetherGuest -> if (isMuted) R.drawable.volume_off else R.drawable.volume_up playbackState == Player.STATE_ENDED -> R.drawable.replay effectiveIsPlaying -> R.drawable.pause else -> R.drawable.play }, ), contentDescription = null, ) } } @Composable private fun LegacyMiniMediaInfo( mediaMetadata: MediaMetadata, pureBlack: Boolean, modifier: Modifier = Modifier, ) { val error by LocalPlayerConnection.current?.error?.collectAsState() ?: remember { mutableStateOf(null) } val cropAlbumArt by rememberPreference(CropAlbumArtKey, false) Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier, ) { Box( modifier = Modifier .padding(6.dp) .size(48.dp) .clip(RoundedCornerShape(ThumbnailCornerRadius)), ) { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceVariant), ) val thumbnailUrl = remember(mediaMetadata.thumbnailUrl) { mediaMetadata.thumbnailUrl?.resize(144, 144) } AsyncImage( model = thumbnailUrl, contentDescription = null, contentScale = if (cropAlbumArt) ContentScale.Crop else ContentScale.Fit, modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(ThumbnailCornerRadius)), ) androidx.compose.animation.AnimatedVisibility(visible = error != null, enter = fadeIn(), exit = fadeOut()) { Box( Modifier .fillMaxSize() .background( color = if (pureBlack) Color.Black else 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), ) } } } Column( modifier = Modifier .weight(1f) .padding(horizontal = 6.dp), ) { Text( text = mediaMetadata.title, color = MaterialTheme.colorScheme.onSurface, fontSize = 16.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.basicMarquee(), ) if (mediaMetadata.artists.any { it.name.isNotBlank() }) { Text( text = mediaMetadata.artists.joinToString { it.name }, color = MaterialTheme.colorScheme.secondary, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } // ============================================================================ // ISOLATED BUTTON COMPOSABLES - Prevent parent recomposition // ============================================================================ @Composable private fun SubscribeButton( artistId: String, metadata: MediaMetadata, ) { val database = LocalDatabase.current val libraryArtist by database.artist(artistId).collectAsState(initial = null) val isSubscribed = libraryArtist?.artist?.bookmarkedAt != null val primaryColor = MaterialTheme.colorScheme.primary val outlineColor = MaterialTheme.colorScheme.outline val onSurfaceColor = MaterialTheme.colorScheme.onSurface Box( contentAlignment = Alignment.Center, modifier = Modifier .size(40.dp) .clip(CircleShape) .border( width = 1.dp, color = if (isSubscribed) primaryColor.copy(alpha = 0.5f) else outlineColor.copy(alpha = 0.3f), shape = CircleShape, ).background( color = if (isSubscribed) primaryColor.copy(alpha = 0.1f) else Color.Transparent, shape = CircleShape, ).clickable { database.transaction { val artist = libraryArtist?.artist if (artist != null) { update(artist.toggleLike()) } else { metadata.artists.firstOrNull()?.let { artistInfo -> insert( ArtistEntity( id = artistInfo.id ?: "", name = artistInfo.name, channelId = null, thumbnailUrl = null, ).toggleLike(), ) } } } }, ) { Icon( painter = painterResource(if (isSubscribed) R.drawable.subscribed else R.drawable.subscribe), contentDescription = null, tint = if (isSubscribed) primaryColor else onSurfaceColor.copy(alpha = 0.7f), modifier = Modifier.size(20.dp), ) } } @Composable private fun FavoriteButton(songId: String) { val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return val librarySong by database.song(songId).collectAsState(initial = null) // For episodes, show saved state (inLibrary); for songs, show liked state val isEpisode = librarySong?.song?.isEpisode == true val isLiked = if (isEpisode) librarySong?.song?.inLibrary != null else librarySong?.song?.liked == true val errorColor = MaterialTheme.colorScheme.error val outlineColor = MaterialTheme.colorScheme.outline val onSurfaceColor = MaterialTheme.colorScheme.onSurface Box( contentAlignment = Alignment.Center, modifier = Modifier .size(40.dp) .clip(CircleShape) .border( width = 1.dp, color = if (isLiked) errorColor.copy(alpha = 0.5f) else outlineColor.copy(alpha = 0.3f), shape = CircleShape, ).background( color = if (isLiked) errorColor.copy(alpha = 0.1f) else Color.Transparent, shape = CircleShape, ).clickable { playerConnection.service.toggleLike() }, ) { Icon( painter = painterResource(if (isLiked) R.drawable.favorite else R.drawable.favorite_border), contentDescription = null, tint = if (isLiked) errorColor else onSurfaceColor.copy(alpha = 0.7f), modifier = Modifier.size(20.dp), ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/player/PlaybackError.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.player 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.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults 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.res.stringResource import androidx.compose.ui.text.font.FontFamily 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.media3.common.PlaybackException import com.metrolist.music.R @Composable fun PlaybackError( error: PlaybackException, retry: () -> Unit, ) { // Build detailed error info for debugging val rawErrorMessage = error.cause?.cause?.message ?: error.cause?.message ?: error.message ?: stringResource(R.string.error_unknown) // Check if this is an age-restricted content error // Age-restricted content typically returns 403 Forbidden or contains age-related messages val isAgeRestricted = rawErrorMessage.contains("age", ignoreCase = true) || rawErrorMessage.contains("Sign in to confirm your age", ignoreCase = true) || rawErrorMessage.contains("LOGIN_REQUIRED", ignoreCase = true) || rawErrorMessage.contains("confirm your age", ignoreCase = true) || rawErrorMessage.contains("403", ignoreCase = true) || rawErrorMessage.contains("Response code: 403", ignoreCase = true) || error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS val errorMessage = if (isAgeRestricted) { "This app does not support playing age-restricted songs. We are working on fixing this issue." } else { rawErrorMessage } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { // Error icon Icon( painter = painterResource(R.drawable.error), contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(48.dp) ) Spacer(modifier = Modifier.height(12.dp)) // Main error message Text( text = stringResource(R.string.error_playback_failed), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(8.dp)) // Error details Text( text = errorMessage, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(4.dp)) // Error code Text( text = "Code: ${getErrorCodeName(error.errorCode)} (${error.errorCode})", style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace, fontSize = 11.sp ), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(16.dp)) // Retry button Button( onClick = retry, shape = RoundedCornerShape(20.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary ) ) { Icon( painter = painterResource(R.drawable.replay), contentDescription = null, modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(6.dp)) Text(text = stringResource(R.string.retry)) } } } /** * Get human-readable error code name from PlaybackException error code */ private fun getErrorCodeName(errorCode: Int): String { return when (errorCode) { PlaybackException.ERROR_CODE_UNSPECIFIED -> "UNSPECIFIED" PlaybackException.ERROR_CODE_REMOTE_ERROR -> "REMOTE_ERROR" PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> "BEHIND_LIVE_WINDOW" PlaybackException.ERROR_CODE_TIMEOUT -> "TIMEOUT" PlaybackException.ERROR_CODE_FAILED_RUNTIME_CHECK -> "FAILED_RUNTIME_CHECK" PlaybackException.ERROR_CODE_IO_UNSPECIFIED -> "IO_UNSPECIFIED" PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> "IO_NETWORK_CONNECTION_FAILED" PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -> "IO_NETWORK_CONNECTION_TIMEOUT" PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> "IO_INVALID_HTTP_CONTENT_TYPE" PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> "IO_BAD_HTTP_STATUS" PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND -> "IO_FILE_NOT_FOUND" PlaybackException.ERROR_CODE_IO_NO_PERMISSION -> "IO_NO_PERMISSION" PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED -> "IO_CLEARTEXT_NOT_PERMITTED" PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE -> "IO_READ_POSITION_OUT_OF_RANGE" PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED -> "PARSING_CONTAINER_MALFORMED" PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> "PARSING_MANIFEST_MALFORMED" PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> "PARSING_CONTAINER_UNSUPPORTED" PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED -> "PARSING_MANIFEST_UNSUPPORTED" PlaybackException.ERROR_CODE_DECODER_INIT_FAILED -> "DECODER_INIT_FAILED" PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> "DECODER_QUERY_FAILED" PlaybackException.ERROR_CODE_DECODING_FAILED -> "DECODING_FAILED" PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> "DECODING_FORMAT_EXCEEDS_CAPABILITIES" PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED -> "DECODING_FORMAT_UNSUPPORTED" PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED -> "AUDIO_TRACK_INIT_FAILED" PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED -> "AUDIO_TRACK_WRITE_FAILED" PlaybackException.ERROR_CODE_DRM_UNSPECIFIED -> "DRM_UNSPECIFIED" PlaybackException.ERROR_CODE_DRM_SCHEME_UNSUPPORTED -> "DRM_SCHEME_UNSUPPORTED" PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED -> "DRM_PROVISIONING_FAILED" PlaybackException.ERROR_CODE_DRM_CONTENT_ERROR -> "DRM_CONTENT_ERROR" PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED -> "DRM_LICENSE_ACQUISITION_FAILED" PlaybackException.ERROR_CODE_DRM_DISALLOWED_OPERATION -> "DRM_DISALLOWED_OPERATION" PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR -> "DRM_SYSTEM_ERROR" PlaybackException.ERROR_CODE_DRM_DEVICE_REVOKED -> "DRM_DEVICE_REVOKED" PlaybackException.ERROR_CODE_DRM_LICENSE_EXPIRED -> "DRM_LICENSE_EXPIRED" else -> "UNKNOWN_ERROR_$errorCode" } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.player import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.res.Configuration import android.view.WindowManager import android.widget.Toast import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState 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.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState 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.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.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.mutableFloatStateOf 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.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.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.toArgb 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.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult 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.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import androidx.core.view.WindowCompat import androidx.datastore.preferences.core.edit import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.Player.STATE_ENDED import androidx.navigation.NavController import androidx.palette.graphics.Palette import coil3.compose.AsyncImage import coil3.imageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.toBitmap import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.CropAlbumArtKey import com.metrolist.music.constants.DarkModeKey import com.metrolist.music.constants.HidePlayerThumbnailKey import com.metrolist.music.constants.KeepScreenOn import com.metrolist.music.constants.PlayerBackgroundStyle import com.metrolist.music.constants.PlayerBackgroundStyleKey import com.metrolist.music.constants.PlayerButtonsStyle import com.metrolist.music.constants.PlayerButtonsStyleKey import com.metrolist.music.constants.PlayerHorizontalPadding import com.metrolist.music.constants.QueuePeekHeight import com.metrolist.music.constants.SleepTimerDefaultKey import com.metrolist.music.constants.SleepTimerFadeOutKey import com.metrolist.music.constants.SleepTimerStopAfterCurrentSongKey import com.metrolist.music.constants.SliderStyle import com.metrolist.music.constants.SliderStyleKey import com.metrolist.music.constants.SquigglySliderKey import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.constants.UseNewPlayerDesignKey import com.metrolist.music.db.entities.LyricsEntity import com.metrolist.music.extensions.togglePlayPause import com.metrolist.music.extensions.toggleRepeatMode import com.metrolist.music.listentogether.RoomRole import com.metrolist.music.models.MediaMetadata import com.metrolist.music.ui.component.BottomSheet import com.metrolist.music.ui.component.BottomSheetState import com.metrolist.music.ui.component.LocalBottomSheetPageState import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.Lyrics import com.metrolist.music.ui.component.PlayerSliderTrack import com.metrolist.music.ui.component.ResizableIconButton import com.metrolist.music.ui.component.SquigglySlider import com.metrolist.music.ui.component.WavySlider import com.metrolist.music.ui.component.rememberBottomSheetState import com.metrolist.music.ui.menu.PlayerMenu import com.metrolist.music.ui.screens.settings.DarkMode import com.metrolist.music.ui.theme.PlayerColorExtractor import com.metrolist.music.ui.theme.PlayerSliderColors import com.metrolist.music.ui.utils.ShowMediaInfo import com.metrolist.music.ui.utils.ShowOffsetDialog import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.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 kotlin.math.max import kotlin.math.roundToInt import com.metrolist.music.ui.component.Icon as MIcon import com.metrolist.music.constants.SleepTimerDefaultKey import com.metrolist.music.utils.dataStore import androidx.datastore.preferences.core.edit import com.metrolist.music.constants.SleepTimerFadeOutKey import com.metrolist.music.constants.SleepTimerStopAfterCurrentSongKey @OptIn(ExperimentalMaterial3Api::class) @Composable fun BottomSheetPlayer( state: BottomSheetState, navController: NavController, modifier: Modifier = Modifier, pureBlack: Boolean, ) { val context = LocalContext.current val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val menuState = LocalMenuState.current val sleepTimerDefaultSetTemplate = stringResource(R.string.sleep_timer_default_set) val copiedTitleStr = stringResource(R.string.copied_title) val copiedArtistStr = stringResource(R.string.copied_artist) val bottomSheetPageState = LocalBottomSheetPageState.current val playerConnection = LocalPlayerConnection.current ?: return val (useNewPlayerDesign, onUseNewPlayerDesignChange) = rememberPreference( UseNewPlayerDesignKey, defaultValue = true, ) val (hidePlayerThumbnail, onHidePlayerThumbnailChange) = rememberPreference(HidePlayerThumbnailKey, false) val cropAlbumArt by rememberPreference(CropAlbumArtKey, false) val playerBackground by rememberEnumPreference( key = PlayerBackgroundStyleKey, defaultValue = PlayerBackgroundStyle.DEFAULT, ) val playerButtonsStyle by rememberEnumPreference( key = PlayerButtonsStyleKey, defaultValue = PlayerButtonsStyle.DEFAULT, ) val isSystemInDarkTheme = isSystemInDarkTheme() val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON } val shouldUseDarkButtonColors = remember(playerBackground, useDarkTheme) { when (playerBackground) { PlayerBackgroundStyle.BLUR, PlayerBackgroundStyle.GRADIENT -> true PlayerBackgroundStyle.DEFAULT -> useDarkTheme } } val isPlaying by playerConnection.isPlaying.collectAsState() val isKeepScreenOn by rememberPreference(KeepScreenOn, false) val keepScreenOn = isPlaying && isKeepScreenOn DisposableEffect(playerBackground, state.isExpanded, useDarkTheme, keepScreenOn) { val window = (context as? android.app.Activity)?.window if (window != null && state.isExpanded) { val insetsController = WindowCompat.getInsetsController(window, window.decorView) when (playerBackground) { PlayerBackgroundStyle.BLUR, PlayerBackgroundStyle.GRADIENT -> { insetsController.isAppearanceLightStatusBars = false } PlayerBackgroundStyle.DEFAULT -> { insetsController.isAppearanceLightStatusBars = !useDarkTheme } } if (keepScreenOn && state.isExpanded) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } onDispose { if (window != null) { val insetsController = WindowCompat.getInsetsController(window, window.decorView) insetsController.isAppearanceLightStatusBars = !useDarkTheme window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } } val onBackgroundColor = when (playerBackground) { PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.secondary else -> MaterialTheme.colorScheme.onSurface } val useBlackBackground = remember(isSystemInDarkTheme, darkTheme, pureBlack) { val useDarkTheme = if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON useDarkTheme && pureBlack } val playbackState by playerConnection.playbackState.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 isMuted by playerConnection.isMuted.collectAsState() val sliderStyle by rememberEnumPreference(SliderStyleKey, SliderStyle.DEFAULT) val squigglySlider by rememberPreference(SquigglySliderKey, defaultValue = false) // Listen Together state (reactive) val listenTogetherManager = LocalListenTogetherManager.current val listenTogetherRoleState = listenTogetherManager?.role?.collectAsState(initial = RoomRole.NONE) val isListenTogetherGuest = listenTogetherRoleState?.value == RoomRole.GUEST // Cast state - safely access castConnectionHandler to prevent crashes during service lifecycle changes val castHandler = remember(playerConnection) { try { playerConnection.service.castConnectionHandler } catch (e: Exception) { null } } val isCasting by castHandler?.isCasting?.collectAsState() ?: remember { mutableStateOf(false) } val castPosition by castHandler?.castPosition?.collectAsState() ?: remember { mutableLongStateOf(0L) } val castDuration by castHandler?.castDuration?.collectAsState() ?: remember { mutableLongStateOf(0L) } val castIsPlaying by castHandler?.castIsPlaying?.collectAsState() ?: remember { mutableStateOf(false) } // Use Cast state when casting, otherwise local player val effectiveIsPlaying = if (isCasting) castIsPlaying else isPlaying // Use State objects for position/duration to pass to MiniPlayer without causing recomposition // These states persist across playback state changes to ensure continuous progress updates val positionState = remember { mutableLongStateOf(0L) } val durationState = remember { mutableLongStateOf(0L) } // Convenience accessors for local use var position by positionState var duration by durationState val effectivePosition by remember { derivedStateOf { if (isCasting) { castPosition } else { position } } } var sliderPosition by remember { mutableStateOf(null) } // Track when we last manually set position to avoid Cast overwriting it var lastManualSeekTime by remember { mutableLongStateOf(0L) } var gradientColors by remember { mutableStateOf>(emptyList()) } val gradientColorsCache = remember { mutableMapOf>() } if (!canSkipNext && automix.isNotEmpty()) { playerConnection.service.addToQueueAutomix(automix[0], 0) } val defaultGradientColors = listOf(MaterialTheme.colorScheme.surface, MaterialTheme.colorScheme.surfaceVariant) val fallbackColor = MaterialTheme.colorScheme.surface.toArgb() LaunchedEffect(mediaMetadata?.id, playerBackground) { if (playerBackground == PlayerBackgroundStyle.GRADIENT) { val currentMetadata = mediaMetadata if (currentMetadata != null && currentMetadata.thumbnailUrl != null) { val cachedColors = gradientColorsCache[currentMetadata.id] if (cachedColors != null) { gradientColors = cachedColors return@LaunchedEffect } withContext(Dispatchers.IO) { val request = ImageRequest .Builder(context) .data(currentMetadata.thumbnailUrl) .size(100, 100) .allowHardware(false) .memoryCacheKey("gradient_${currentMetadata.id}") .build() val result = runCatching { context.imageLoader.execute(request) }.getOrNull() if (result != null) { val bitmap = result.image?.toBitmap() if (bitmap != null) { val palette = withContext(Dispatchers.Default) { Palette .from(bitmap) .maximumColorCount(8) .resizeBitmapArea(100 * 100) .generate() } val extractedColors = PlayerColorExtractor.extractGradientColors( palette = palette, fallbackColor = fallbackColor, ) gradientColorsCache[currentMetadata.id] = extractedColors withContext(Dispatchers.Main) { gradientColors = extractedColors } } } } } } else { gradientColors = emptyList() } } val TextBackgroundColor by animateColorAsState( targetValue = when (playerBackground) { PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.onBackground PlayerBackgroundStyle.BLUR -> Color.White PlayerBackgroundStyle.GRADIENT -> Color.White }, label = "TextBackgroundColor", ) val icBackgroundColor by animateColorAsState( targetValue = when (playerBackground) { PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.surface PlayerBackgroundStyle.BLUR -> Color.Black PlayerBackgroundStyle.GRADIENT -> Color.Black }, label = "icBackgroundColor", ) val (textButtonColor, iconButtonColor) = when { playerBackground == PlayerBackgroundStyle.BLUR || playerBackground == PlayerBackgroundStyle.GRADIENT -> { when (playerButtonsStyle) { PlayerButtonsStyle.DEFAULT -> { Pair(Color.White, Color.Black) } PlayerButtonsStyle.PRIMARY -> { Pair( MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary, ) } PlayerButtonsStyle.TERTIARY -> { Pair( MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.onTertiary, ) } } } else -> { 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, ) } } } } // Separate colors for Previous/Next buttons in PRIMARY/TERTIARY modes val (sideButtonContainerColor, sideButtonContentColor) = when { playerBackground == PlayerBackgroundStyle.BLUR || playerBackground == PlayerBackgroundStyle.GRADIENT -> { when (playerButtonsStyle) { PlayerButtonsStyle.DEFAULT -> { Pair( Color.White.copy(alpha = 0.2f), Color.White, ) } PlayerButtonsStyle.PRIMARY -> { Pair( MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer, ) } PlayerButtonsStyle.TERTIARY -> { Pair( MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer, ) } } } else -> { when (playerButtonsStyle) { PlayerButtonsStyle.DEFAULT -> { Pair( MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface, ) } PlayerButtonsStyle.PRIMARY -> { Pair( MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer, ) } PlayerButtonsStyle.TERTIARY -> { Pair( MaterialTheme.colorScheme.tertiaryContainer, MaterialTheme.colorScheme.onTertiaryContainer, ) } } } } 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) } } } val scope = rememberCoroutineScope() var showSleepTimerDialog by remember { mutableStateOf(false) } val sleepTimerDefault by rememberPreference(SleepTimerDefaultKey, 30f) var sleepTimerValue by remember { mutableFloatStateOf(sleepTimerDefault) } val isAtDefault by remember { derivedStateOf { sleepTimerValue.roundToInt() == sleepTimerDefault.roundToInt() } } val sleepTimerStopAfterCurrentSong by rememberPreference(SleepTimerStopAfterCurrentSongKey, false) val sleepTimerFadeOut by rememberPreference(SleepTimerFadeOutKey, false) 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( minute = sleepTimerValue.roundToInt(), stopAfterCurrentSong = sleepTimerStopAfterCurrentSong, fadeOut = sleepTimerFadeOut, ) }, ) { 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, ) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (isAtDefault) { FilledIconButton( onClick = { scope.launch { context.dataStore.edit { settings -> settings[SleepTimerDefaultKey] = sleepTimerValue } } Toast.makeText( context, String.format(sleepTimerDefaultSetTemplate, sleepTimerValue.roundToInt()), Toast.LENGTH_SHORT, ).show() }, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, ), ) { Text(stringResource(R.string.set_as_default)) } } else { OutlinedIconButton( onClick = { scope.launch { context.dataStore.edit { settings -> settings[SleepTimerDefaultKey] = sleepTimerValue } } Toast.makeText( context, String.format(sleepTimerDefaultSetTemplate, sleepTimerValue.roundToInt()), Toast.LENGTH_SHORT, ).show() }, ) { Text(stringResource(R.string.set_as_default)) } } OutlinedIconButton( onClick = { showSleepTimerDialog = false playerConnection.service.sleepTimer.start(minute = -1) }, ) { Text(stringResource(R.string.end_of_song)) } } } }, ) } var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) } var showInlineLyrics by rememberSaveable { mutableStateOf(false) } var isFullScreen by rememberSaveable { mutableStateOf(false) } // Position update - only for local playback // When casting, we use castPosition directly to avoid sync issues // Use isPlaying instead of playbackState to ensure continuous updates during playback LaunchedEffect(isPlaying, isCasting) { if (!isCasting && isPlaying) { while (isActive) { delay(100) // Update more frequently for smoother progress bar if (sliderPosition == null) { // Only update if user isn't dragging position = playerConnection.player.currentPosition duration = playerConnection.player.duration } } } } // Also update position when playback state changes (e.g., song change, seek) LaunchedEffect(playbackState, mediaMetadata?.id) { if (!isCasting) { position = playerConnection.player.currentPosition duration = playerConnection.player.duration } } // When casting, use Cast position/duration directly // But wait a bit after manual seeks to let Cast catch up LaunchedEffect(isCasting, castPosition, castDuration) { if (isCasting && sliderPosition == null) { val timeSinceManualSeek = System.currentTimeMillis() - lastManualSeekTime if (timeSinceManualSeek > 1500) { // Only update from Cast if we haven't manually seeked recently position = castPosition if (castDuration > 0) duration = castDuration } } } val dismissedBound = QueuePeekHeight + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() val queueSheetState = rememberBottomSheetState( dismissedBound = dismissedBound, expandedBound = state.expandedBound, collapsedBound = dismissedBound + 1.dp, initialAnchor = 1, ) val bottomSheetBackgroundColor = when (playerBackground) { PlayerBackgroundStyle.BLUR, PlayerBackgroundStyle.GRADIENT -> { MaterialTheme.colorScheme.surfaceContainer } else -> { if (useBlackBackground) { Color.Black } else { MaterialTheme.colorScheme.surfaceContainer } } } val backgroundAlpha = state.progress.coerceIn(0f, 1f) 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 = if (!isListenTogetherGuest) { { playerConnection.service.clearAutomix() playerConnection.player.stop() playerConnection.player.clearMediaItems() } } else { null }, collapsedContent = { MiniPlayer( positionState = positionState, durationState = durationState, ) }, ) { 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 = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(horizontal = PlayerHorizontalPadding), ) { AnimatedContent( targetState = showInlineLyrics, label = "ThumbnailAnimation", ) { showLyrics -> if (showLyrics) { Row { if (hidePlayerThumbnail) { Box( modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(ThumbnailCornerRadius)) .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.small_icon), contentDescription = null, modifier = Modifier .size(32.dp), tint = textButtonColor.copy(alpha = 0.7f), ) } } else { AsyncImage( model = mediaMetadata.thumbnailUrl, contentDescription = null, contentScale = if (cropAlbumArt) ContentScale.Crop else ContentScale.Fit, modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(ThumbnailCornerRadius)), ) } Spacer(modifier = Modifier.width(12.dp)) } } else { Spacer(modifier = Modifier.width(0.dp)) } } Column( modifier = Modifier.weight(1f), ) { 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 = TextBackgroundColor, modifier = Modifier .basicMarquee(iterations = 1, initialDelayMillis = 3000, velocity = 30.dp) .combinedClickable( enabled = true, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { if (mediaMetadata.album != null) { navController.navigate("album/${mediaMetadata.album.id}") state.collapseSoft() } }, onLongClick = { val clip = ClipData.newPlainText(copiedTitleStr, title) clipboardManager.setPrimaryClip(clip) Toast .makeText(context, copiedTitleStr, Toast.LENGTH_SHORT) .show() }, ), ) } Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { if (mediaMetadata.explicit) MIcon.Explicit() if (mediaMetadata.artists.any { it.name.isNotBlank() }) { val annotatedString = buildAnnotatedString { mediaMetadata.artists.forEachIndexed { index, artist -> val tag = "artist_${artist.id.orEmpty()}" pushStringAnnotation(tag = tag, annotation = artist.id.orEmpty()) withStyle(SpanStyle(color = TextBackgroundColor, fontSize = 16.sp)) { append(artist.name) } pop() if (index != mediaMetadata.artists.lastIndex) append(", ") } } Box( modifier = Modifier .fillMaxWidth() .basicMarquee(iterations = 1, initialDelayMillis = 3000, velocity = 30.dp) .padding(end = 12.dp), ) { var layoutResult by remember { mutableStateOf(null) } var clickOffset by remember { mutableStateOf(null) } Text( text = annotatedString, style = MaterialTheme.typography.titleMedium.copy(color = TextBackgroundColor), maxLines = 1, overflow = TextOverflow.Ellipsis, onTextLayout = { layoutResult = it }, modifier = Modifier .pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent() val tapPosition = event.changes.firstOrNull()?.position if (tapPosition != null) { clickOffset = tapPosition } } } }.combinedClickable( enabled = true, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = { val tapPosition = clickOffset val layout = layoutResult if (tapPosition != null && layout != null) { val offset = layout.getOffsetForPosition(tapPosition) annotatedString .getStringAnnotations(offset, offset) .firstOrNull() ?.let { ann -> val artistId = ann.item if (artistId.isNotBlank()) { navController.navigate("artist/$artistId") state.collapseSoft() } } } }, onLongClick = { val clip = ClipData.newPlainText( copiedArtistStr, annotatedString, ) clipboardManager.setPrimaryClip(clip) Toast .makeText( context, copiedArtistStr, Toast.LENGTH_SHORT, ).show() }, ), ) } } } } Spacer(modifier = Modifier.width(12.dp)) if (useNewPlayerDesign) { val shareShape = RoundedCornerShape( topStart = 50.dp, bottomStart = 50.dp, topEnd = 3.dp, bottomEnd = 3.dp, ) val favShape = RoundedCornerShape( topStart = 3.dp, bottomStart = 3.dp, topEnd = 50.dp, bottomEnd = 50.dp, ) val middleShape = RoundedCornerShape(3.dp) Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { AnimatedContent(targetState = showInlineLyrics, label = "ShareButton") { showLyrics -> if (showLyrics) { FilledIconButton( onClick = { isFullScreen = !isFullScreen }, shape = shareShape, colors = IconButtonDefaults.filledIconButtonColors( containerColor = textButtonColor, contentColor = iconButtonColor, ), modifier = Modifier.size(42.dp), ) { Icon( painter = painterResource(R.drawable.fullscreen), contentDescription = null, modifier = Modifier.size(24.dp), ) } } else { FilledIconButton( onClick = { 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)) }, shape = shareShape, colors = IconButtonDefaults.filledIconButtonColors( containerColor = textButtonColor, contentColor = iconButtonColor, ), modifier = Modifier.size(42.dp), ) { Icon( painter = painterResource(R.drawable.share), contentDescription = null, modifier = Modifier.size(24.dp), ) } } } AnimatedContent(targetState = showInlineLyrics, label = "LikeButton") { showLyrics -> if (showLyrics) { val currentLyrics by playerConnection.currentLyrics.collectAsState(initial = null) FilledIconButton( onClick = { menuState.show { com.metrolist.music.ui.menu.LyricsMenu( lyricsProvider = { currentLyrics }, songProvider = { currentSong?.song }, mediaMetadataProvider = { mediaMetadata }, onDismiss = menuState::dismiss, onShowOffsetDialog = { bottomSheetPageState.show { ShowOffsetDialog( songProvider = { currentSong?.song }, ) } }, ) } }, shape = favShape, colors = IconButtonDefaults.filledIconButtonColors( containerColor = textButtonColor, contentColor = iconButtonColor, ), modifier = Modifier.size(42.dp), ) { Icon( painter = painterResource(R.drawable.more_horiz), contentDescription = null, modifier = Modifier.size(24.dp), ) } } else { // For episodes, show saved state (inLibrary); for songs, show liked state val isEpisode = currentSong?.song?.isEpisode == true val isFavorite = if (isEpisode) currentSong?.song?.inLibrary != null else currentSong?.song?.liked == true FilledIconButton( onClick = playerConnection::toggleLike, shape = favShape, colors = IconButtonDefaults.filledIconButtonColors( containerColor = textButtonColor, contentColor = iconButtonColor, ), modifier = Modifier.size(42.dp), ) { Icon( painter = painterResource( if (isFavorite) { R.drawable.favorite } else { R.drawable.favorite_border }, ), contentDescription = null, modifier = Modifier.size(24.dp), ) } } } } } else { AnimatedContent(targetState = showInlineLyrics, label = "ShareButton") { showLyrics -> if (showLyrics) { Box( modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(24.dp)) .background(textButtonColor) .clickable { isFullScreen = !isFullScreen }, ) { Icon( painter = painterResource(R.drawable.fullscreen), contentDescription = null, tint = iconButtonColor, modifier = Modifier .align(Alignment.Center) .size(24.dp), ) } } else { Box( modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(24.dp)) .background(textButtonColor) .clickable { 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)) }, ) { Icon( painter = painterResource(R.drawable.share), contentDescription = null, tint = iconButtonColor, modifier = Modifier .align(Alignment.Center) .size(24.dp), ) } } } Spacer(modifier = Modifier.size(12.dp)) AnimatedContent(targetState = showInlineLyrics, label = "LikeButton") { showLyrics -> if (showLyrics) { val currentLyrics by playerConnection.currentLyrics.collectAsState(initial = null) Box( modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(24.dp)) .background(textButtonColor) .clickable { menuState.show { com.metrolist.music.ui.menu.LyricsMenu( lyricsProvider = { currentLyrics }, songProvider = { currentSong?.song }, mediaMetadataProvider = { mediaMetadata }, onDismiss = menuState::dismiss, onShowOffsetDialog = { bottomSheetPageState.show { ShowOffsetDialog( songProvider = { currentSong?.song }, ) } }, ) } }, ) { Icon( painter = painterResource(R.drawable.more_horiz), contentDescription = null, tint = iconButtonColor, modifier = Modifier .align(Alignment.Center) .size(24.dp), ) } } else { PlayerMoreMenuButton( mediaMetadata = mediaMetadata, navController = navController, state = state, textButtonColor = textButtonColor, iconButtonColor = iconButtonColor, ) } } } } Spacer(Modifier.height(24.dp)) when (sliderStyle) { SliderStyle.DEFAULT -> { Slider( value = (sliderPosition ?: effectivePosition).toFloat(), valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), onValueChange = { if (!isListenTogetherGuest) { sliderPosition = it.toLong() } }, onValueChangeFinished = { if (!isListenTogetherGuest) { sliderPosition?.let { if (isCasting) { castHandler?.seekTo(it) lastManualSeekTime = System.currentTimeMillis() } else { playerConnection.player.seekTo(it) } position = it } sliderPosition = null } }, enabled = !isListenTogetherGuest, colors = PlayerSliderColors.getSliderColors(textButtonColor, playerBackground, useDarkTheme), modifier = Modifier.padding(horizontal = PlayerHorizontalPadding), ) } SliderStyle.WAVY -> { if (squigglySlider) { SquigglySlider( value = (sliderPosition ?: effectivePosition).toFloat(), valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), onValueChange = { sliderPosition = it.toLong() }, onValueChangeFinished = { sliderPosition?.let { if (isCasting) { castHandler?.seekTo(it) lastManualSeekTime = System.currentTimeMillis() } else { playerConnection.player.seekTo(it) } position = it } sliderPosition = null }, modifier = Modifier.padding(horizontal = PlayerHorizontalPadding), colors = PlayerSliderColors.getSliderColors(textButtonColor, playerBackground, useDarkTheme), isPlaying = effectiveIsPlaying, ) } else { WavySlider( value = (sliderPosition ?: effectivePosition).toFloat(), valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), onValueChange = { sliderPosition = it.toLong() }, onValueChangeFinished = { sliderPosition?.let { if (isCasting) { castHandler?.seekTo(it) lastManualSeekTime = System.currentTimeMillis() } else { playerConnection.player.seekTo(it) } position = it } sliderPosition = null }, colors = PlayerSliderColors.getSliderColors(textButtonColor, playerBackground, useDarkTheme), modifier = Modifier.padding(horizontal = PlayerHorizontalPadding), isPlaying = effectiveIsPlaying, ) } } SliderStyle.SLIM -> { Slider( value = (sliderPosition ?: effectivePosition).toFloat(), valueRange = 0f..(if (duration == C.TIME_UNSET) 0f else duration.toFloat()), onValueChange = { if (!isListenTogetherGuest) { sliderPosition = it.toLong() } }, onValueChangeFinished = { if (!isListenTogetherGuest) { sliderPosition?.let { if (isCasting) { castHandler?.seekTo(it) lastManualSeekTime = System.currentTimeMillis() } else { playerConnection.player.seekTo(it) } position = it } sliderPosition = null } }, enabled = !isListenTogetherGuest, thumb = { Spacer(modifier = Modifier.size(0.dp)) }, track = { sliderState -> PlayerSliderTrack( sliderState = sliderState, colors = PlayerSliderColors.getSliderColors(textButtonColor, playerBackground, 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 ?: effectivePosition), 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(24.dp)) AnimatedVisibility( visible = !isFullScreen, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = shrinkVertically(shrinkTowards = Alignment.Top) + slideOutVertically(targetOffsetY = { it }) + fadeOut(), ) { Column { if (useNewPlayerDesign) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(horizontal = PlayerHorizontalPadding), ) { val backInteractionSource = remember { MutableInteractionSource() } val nextInteractionSource = remember { MutableInteractionSource() } val playPauseInteractionSource = remember { MutableInteractionSource() } val isPlayPausePressed by playPauseInteractionSource.collectIsPressedAsState() val isBackPressed by backInteractionSource.collectIsPressedAsState() val isNextPressed by nextInteractionSource.collectIsPressedAsState() val playPauseWeight by animateFloatAsState( targetValue = if (isPlayPausePressed) { 1.9f } else if (isBackPressed || isNextPressed) { 1.1f } else { 1.3f }, animationSpec = spring( dampingRatio = 0.6f, stiffness = 500f, ), label = "playPauseWeight", ) val backButtonWeight by animateFloatAsState( targetValue = if (isBackPressed) { 0.65f } else if (isPlayPausePressed) { 0.35f } else { 0.45f }, animationSpec = spring( dampingRatio = 0.6f, stiffness = 500f, ), label = "backButtonWeight", ) val nextButtonWeight by animateFloatAsState( targetValue = if (isNextPressed) { 0.65f } else if (isPlayPausePressed) { 0.35f } else { 0.45f }, animationSpec = spring( dampingRatio = 0.6f, stiffness = 500f, ), label = "nextButtonWeight", ) FilledIconButton( onClick = playerConnection::seekToPrevious, enabled = canSkipPrevious && !isListenTogetherGuest, shape = RoundedCornerShape(50), interactionSource = backInteractionSource, colors = IconButtonDefaults.filledIconButtonColors( containerColor = sideButtonContainerColor, contentColor = sideButtonContentColor, ), modifier = Modifier .height(68.dp) .weight(backButtonWeight), ) { Icon( painter = painterResource(R.drawable.skip_previous), contentDescription = null, modifier = Modifier.size(32.dp), ) } Spacer(modifier = Modifier.width(8.dp)) FilledIconButton( onClick = { if (isListenTogetherGuest) { playerConnection.toggleMute() return@FilledIconButton } if (isCasting) { if (castIsPlaying) { castHandler?.pause() } else { castHandler?.play() } } else if (playbackState == STATE_ENDED) { playerConnection.player.seekTo(0, 0) playerConnection.player.playWhenReady = true } else { playerConnection.togglePlayPause() } }, shape = RoundedCornerShape(50), interactionSource = playPauseInteractionSource, colors = IconButtonDefaults.filledIconButtonColors( containerColor = textButtonColor, contentColor = iconButtonColor, ), modifier = Modifier .height(68.dp) .weight(playPauseWeight), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { Icon( painter = painterResource( if (isListenTogetherGuest) { if (isMuted) R.drawable.volume_off else R.drawable.volume_up } else { if (effectiveIsPlaying) R.drawable.pause else R.drawable.play }, ), contentDescription = if (isListenTogetherGuest) { if (isMuted) stringResource(R.string.unmute) else stringResource(R.string.mute) } else { if (effectiveIsPlaying) stringResource(R.string.pause) else stringResource(R.string.play) }, modifier = Modifier.size(32.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text( text = if (isListenTogetherGuest) { if (isMuted) stringResource(R.string.unmute) else stringResource(R.string.mute) } else { if (effectiveIsPlaying) stringResource(R.string.pause) else stringResource(R.string.play) }, style = MaterialTheme.typography.titleMedium, ) } } Spacer(modifier = Modifier.width(8.dp)) FilledIconButton( onClick = playerConnection::seekToNext, enabled = canSkipNext && !isListenTogetherGuest, shape = RoundedCornerShape(50), interactionSource = nextInteractionSource, colors = IconButtonDefaults.filledIconButtonColors( containerColor = sideButtonContainerColor, contentColor = sideButtonContentColor, ), modifier = Modifier .height(68.dp) .weight(nextButtonWeight), ) { Icon( painter = painterResource(R.drawable.skip_next), contentDescription = null, modifier = Modifier.size(32.dp), ) } } } else { 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 (isListenTogetherGuest) 0.5f else 1f), enabled = !isListenTogetherGuest, onClick = { playerConnection.player.toggleRepeatMode() }, ) } Box(modifier = Modifier.weight(1f)) { ResizableIconButton( icon = R.drawable.skip_previous, enabled = canSkipPrevious && !isListenTogetherGuest, color = TextBackgroundColor, modifier = Modifier .size(32.dp) .align(Alignment.Center) .alpha(if (isListenTogetherGuest) 0.5f else 1f), onClick = playerConnection::seekToPrevious, ) } Spacer(Modifier.width(8.dp)) Box( modifier = Modifier .size(72.dp) .clip(RoundedCornerShape(playPauseRoundness)) .background(textButtonColor) .clickable { if (isListenTogetherGuest) { playerConnection.toggleMute() return@clickable } if (isCasting) { if (castIsPlaying) { castHandler?.pause() } else { castHandler?.play() } } else if (playbackState == STATE_ENDED) { playerConnection.player.seekTo(0, 0) playerConnection.player.playWhenReady = true } else { playerConnection.player.togglePlayPause() } }, ) { Image( painter = painterResource( if (isListenTogetherGuest) { if (isMuted) R.drawable.volume_off else R.drawable.volume_up } else if (playbackState == STATE_ENDED ) { R.drawable.replay } else if (effectiveIsPlaying) { R.drawable.pause } else { R.drawable.play }, ), contentDescription = null, colorFilter = ColorFilter.tint(iconButtonColor), modifier = Modifier .align(Alignment.Center) .size(36.dp), ) } Spacer(Modifier.width(8.dp)) Box(modifier = Modifier.weight(1f)) { ResizableIconButton( icon = R.drawable.skip_next, enabled = canSkipNext && !isListenTogetherGuest, color = TextBackgroundColor, modifier = Modifier .size(32.dp) .align(Alignment.Center) .alpha(if (isListenTogetherGuest) 0.5f else 1f), onClick = playerConnection::seekToNext, ) } Box(modifier = Modifier.weight(1f)) { // For episodes, show saved state (inLibrary); for songs, show liked state val isEpisode = currentSong?.song?.isEpisode == true val isFavorite = if (isEpisode) currentSong?.song?.inLibrary != null else currentSong?.song?.liked == true ResizableIconButton( icon = if (isFavorite) R.drawable.favorite else R.drawable.favorite_border, color = if (isFavorite) MaterialTheme.colorScheme.error else TextBackgroundColor, modifier = Modifier .size(32.dp) .padding(4.dp) .align(Alignment.Center), onClick = playerConnection::toggleLike, ) } } } } } } when (LocalConfiguration.current.orientation) { Configuration.ORIENTATION_LANDSCAPE -> { // Calculate vertical padding like OuterTune val density = LocalDensity.current val verticalPadding = max( WindowInsets.systemBars.getTop(density), WindowInsets.systemBars.getBottom(density), ) val verticalPaddingDp = with(density) { verticalPadding.toDp() } val verticalWindowInsets = WindowInsets(left = 0.dp, top = verticalPaddingDp, right = 0.dp, bottom = verticalPaddingDp) Row( modifier = Modifier .windowInsetsPadding( WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).add(verticalWindowInsets), ).padding(bottom = 24.dp) .fillMaxSize(), ) { Box( contentAlignment = Alignment.Center, modifier = Modifier .weight(1f) .nestedScroll(state.preUpPostDownNestedScrollConnection), ) { // Remember lambdas to prevent unnecessary recomposition val currentSliderPosition by rememberUpdatedState(sliderPosition) val sliderPositionProvider = remember { { currentSliderPosition } } val isExpandedProvider = remember(state) { { state.isExpanded } } AnimatedContent( targetState = showInlineLyrics, label = "Lyrics", transitionSpec = { fadeIn() togetherWith fadeOut() }, ) { showLyrics -> if (showLyrics) { InlineLyricsView( mediaMetadata = mediaMetadata, showLyrics = showLyrics, positionProvider = { effectivePosition }, ) } else { Thumbnail( sliderPositionProvider = sliderPositionProvider, modifier = Modifier.animateContentSize(), isPlayerExpanded = isExpandedProvider, isLandscape = true, isListenTogetherGuest = isListenTogetherGuest, ) } } } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .weight(if (showInlineLyrics) 0.65f else 1f, false) .animateContentSize() .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), ) { Spacer(Modifier.weight(1f)) mediaMetadata?.let { controlsContent(it) } Spacer(Modifier.weight(1f)) } } } else -> { val bottomPadding by animateDpAsState( targetValue = if (isFullScreen) 0.dp else queueSheetState.collapsedBound, label = "bottomPadding", ) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(bottom = bottomPadding) .animateContentSize(), ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.weight(1f), ) { // Remember lambdas to prevent unnecessary recomposition val currentSliderPosition by rememberUpdatedState(sliderPosition) val sliderPositionProvider = remember { { currentSliderPosition } } val isExpandedProvider = remember(state) { { state.isExpanded } } AnimatedContent( targetState = showInlineLyrics, label = "Lyrics", transitionSpec = { fadeIn() togetherWith fadeOut() }, ) { showLyrics -> if (showLyrics) { InlineLyricsView( mediaMetadata = mediaMetadata, showLyrics = showLyrics, positionProvider = { effectivePosition }, ) } else { Thumbnail( sliderPositionProvider = sliderPositionProvider, modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection), isPlayerExpanded = isExpandedProvider, isListenTogetherGuest = isListenTogetherGuest, ) } } } mediaMetadata?.let { controlsContent(it) } Spacer(Modifier.height(30.dp)) } } } AnimatedVisibility( visible = !isFullScreen, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = shrinkVertically(shrinkTowards = Alignment.Top) + slideOutVertically(targetOffsetY = { it }) + fadeOut(), ) { Queue( state = queueSheetState, playerBottomSheetState = state, navController = navController, background = if (useBlackBackground) { Color.Black } else { MaterialTheme.colorScheme.surfaceContainer }, onBackgroundColor = onBackgroundColor, TextBackgroundColor = TextBackgroundColor, textButtonColor = textButtonColor, iconButtonColor = iconButtonColor, pureBlack = pureBlack, showInlineLyrics = showInlineLyrics, playerBackground = playerBackground, onToggleLyrics = { showInlineLyrics = !showInlineLyrics }, ) } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun InlineLyricsView( mediaMetadata: MediaMetadata?, showLyrics: Boolean, positionProvider: () -> Long, ) { val playerConnection = LocalPlayerConnection.current ?: return val currentLyrics by playerConnection.currentLyrics.collectAsState(initial = null) val lyrics = remember(currentLyrics) { currentLyrics?.lyrics?.trim() } val context = LocalContext.current val database = LocalDatabase.current val coroutineScope = rememberCoroutineScope() LaunchedEffect(mediaMetadata?.id, currentLyrics) { if (mediaMetadata != null && currentLyrics == null) { delay(500) coroutineScope.launch(Dispatchers.IO) { try { val entryPoint = EntryPointAccessors.fromApplication( context.applicationContext, com.metrolist.music.di.LyricsHelperEntryPoint::class.java, ) val lyricsHelper = entryPoint.lyricsHelper() val fetchedLyricsWithProvider = lyricsHelper.getLyrics(mediaMetadata) database.query { upsert(LyricsEntity(mediaMetadata.id, fetchedLyricsWithProvider.lyrics, fetchedLyricsWithProvider.provider)) } } catch (e: Exception) { // Handle error } } } } Box( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(12.dp)), contentAlignment = Alignment.Center, ) { when { lyrics == null -> { ContainedLoadingIndicator() } lyrics == LyricsEntity.LYRICS_NOT_FOUND -> { Text( text = stringResource(R.string.lyrics_not_found), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), textAlign = TextAlign.Center, ) } else -> { val lyricsContent: @Composable () -> Unit = { Lyrics( sliderPositionProvider = positionProvider, modifier = Modifier.padding(horizontal = 24.dp), showLyrics = showLyrics, ) } ProvideTextStyle( value = MaterialTheme.typography.bodyMedium.copy( fontSize = 14.sp, textAlign = TextAlign.Center, ), ) { lyricsContent() } } } } } @Composable fun MoreActionsButton( mediaMetadata: MediaMetadata, navController: NavController, state: BottomSheetState, textButtonColor: Color, iconButtonColor: Color, ) { val menuState = LocalMenuState.current val bottomSheetPageState = LocalBottomSheetPageState.current Box( modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(24.dp)) .background(textButtonColor) .clickable { menuState.show { PlayerMenu( mediaMetadata = mediaMetadata, navController = navController, playerBottomSheetState = state, onShowDetailsDialog = { mediaMetadata.id.let { bottomSheetPageState.show { ShowMediaInfo(it) } } }, onDismiss = menuState::dismiss, ) } }, ) { Image( painter = painterResource(R.drawable.more_horiz), contentDescription = null, colorFilter = ColorFilter.tint(iconButtonColor), ) } } @Composable private fun PlayerMoreMenuButton( mediaMetadata: MediaMetadata, navController: NavController, state: BottomSheetState, textButtonColor: Color, iconButtonColor: Color, ) { val menuState = LocalMenuState.current val bottomSheetPageState = LocalBottomSheetPageState.current Box( contentAlignment = Alignment.Center, modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(24.dp)) .background(textButtonColor) .clickable { menuState.show { PlayerMenu( mediaMetadata = mediaMetadata, navController = navController, playerBottomSheetState = state, onShowDetailsDialog = { mediaMetadata.id.let { bottomSheetPageState.show { ShowMediaInfo(it) } } }, onDismiss = menuState::dismiss, ) } }, ) { Image( painter = painterResource(R.drawable.more_horiz), contentDescription = null, colorFilter = ColorFilter.tint(iconButtonColor), ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.player import android.annotation.SuppressLint import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize 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.basicMarquee import androidx.compose.foundation.border 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.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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Checkbox 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.OutlinedButton import androidx.compose.material3.Slider 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.mutableFloatStateOf 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.rememberUpdatedState import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList 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.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboard 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.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.edit import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.navigation.NavController import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.PlayerBackgroundStyle import com.metrolist.music.constants.QueueEditLockKey import com.metrolist.music.constants.UseNewPlayerDesignKey import com.metrolist.music.extensions.metadata import com.metrolist.music.extensions.move import com.metrolist.music.extensions.toggleRepeatMode import com.metrolist.music.listentogether.RoomRole import com.metrolist.music.models.MediaMetadata import com.metrolist.music.ui.component.ActionPromptDialog import com.metrolist.music.ui.component.BottomSheet import com.metrolist.music.ui.component.BottomSheetState import com.metrolist.music.ui.component.LocalBottomSheetPageState import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.MediaMetadataListItem import com.metrolist.music.ui.menu.PlayerMenu import com.metrolist.music.ui.menu.QueueMenu import com.metrolist.music.ui.menu.SelectionMediaMetadataMenu import com.metrolist.music.ui.utils.ShowMediaInfo import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.makeTimeString import com.metrolist.music.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 import kotlin.math.roundToInt import com.metrolist.music.constants.SleepTimerDefaultKey import com.metrolist.music.utils.dataStore import androidx.datastore.preferences.core.edit import android.widget.Toast import androidx.compose.runtime.derivedStateOf import com.metrolist.music.constants.SleepTimerFadeOutKey import com.metrolist.music.constants.SleepTimerStopAfterCurrentSongKey import androidx.compose.runtime.derivedStateOf import androidx.compose.material3.Button @SuppressLint("UnrememberedMutableState") @OptIn(ExperimentalFoundationApi::class) @Composable fun Queue( state: BottomSheetState, playerBottomSheetState: BottomSheetState, navController: NavController, modifier: Modifier = Modifier, background: Color, onBackgroundColor: Color, TextBackgroundColor: Color, textButtonColor: Color, iconButtonColor: Color, pureBlack: Boolean, showInlineLyrics: Boolean, playerBackground: PlayerBackgroundStyle = PlayerBackgroundStyle.DEFAULT, onToggleLyrics: () -> Unit = {}, ) { val context = LocalContext.current val haptic = LocalHapticFeedback.current val clipboardManager = LocalClipboard.current val menuState = LocalMenuState.current val sleepTimerDefaultSetTemplate = stringResource(R.string.sleep_timer_default_set) val bottomSheetPageState = LocalBottomSheetPageState.current // Listen Together state (reactive) val listenTogetherManager = LocalListenTogetherManager.current val listenTogetherRoleState = listenTogetherManager?.role?.collectAsState(initial = com.metrolist.music.listentogether.RoomRole.NONE) val isListenTogetherGuest = listenTogetherRoleState?.value == RoomRole.GUEST val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.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 = remember { mutableStateListOf() } val selectedItems = remember { mutableStateListOf() } // Cast state - safely access castConnectionHandler to prevent crashes during service lifecycle changes val castHandler = remember(playerConnection) { try { playerConnection.service.castConnectionHandler } catch (e: Exception) { null } } val isCasting by castHandler?.isCasting?.collectAsState() ?: remember { mutableStateOf(false) } val castIsPlaying by castHandler?.castIsPlaying?.collectAsState() ?: remember { mutableStateOf(false) } var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, String>( save = { it.toList() }, restore = { it.toMutableStateList() }, ), ) { mutableStateListOf() } val onExitSelectionMode = { inSelectMode = false selection.clear() } if (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } var locked by rememberPreference(QueueEditLockKey, defaultValue = true) val (useNewPlayerDesign, onUseNewPlayerDesignChange) = rememberPreference( UseNewPlayerDesignKey, defaultValue = true, ) val snackbarHostState = remember { SnackbarHostState() } var dismissJob: Job? by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() var showSleepTimerDialog by remember { mutableStateOf(false) } val sleepTimerDefault by rememberPreference(SleepTimerDefaultKey, 30f) var sleepTimerValue by remember { mutableFloatStateOf(sleepTimerDefault) } val isAtDefault by remember { derivedStateOf { sleepTimerValue.roundToInt() == sleepTimerDefault.roundToInt() } } val sleepTimerStopAfterCurrentSong by rememberPreference(SleepTimerStopAfterCurrentSongKey, false) val sleepTimerFadeOut by rememberPreference(SleepTimerFadeOutKey, false) 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) } } } BottomSheet( state = state, modifier = modifier, background = { Box(Modifier.fillMaxSize().background(Color.Unspecified)) }, collapsedContent = { if (useNewPlayerDesign) { // New design Row( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(horizontal = 30.dp, vertical = 12.dp) .windowInsetsPadding( WindowInsets.systemBars.only( WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal, ), ), ) { val buttonSize = 42.dp val iconSize = 24.dp val queueShape = RoundedCornerShape( topStart = 50.dp, bottomStart = 50.dp, topEnd = 3.dp, bottomEnd = 3.dp, ) val middleShape = RoundedCornerShape(3.dp) val repeatShape = RoundedCornerShape( topStart = 3.dp, bottomStart = 3.dp, topEnd = 50.dp, bottomEnd = 50.dp, ) PlayerQueueButton( icon = R.drawable.queue_music, onClick = { state.expandSoft() }, isActive = false, shape = queueShape, modifier = Modifier.size(buttonSize), textButtonColor = textButtonColor, iconButtonColor = iconButtonColor, iconSize = iconSize, textBackgroundColor = TextBackgroundColor, playerBackground = playerBackground, ) PlayerQueueButton( icon = R.drawable.bedtime, onClick = { if (sleepTimerEnabled) { playerConnection.service.sleepTimer.clear() } else { showSleepTimerDialog = true } }, isActive = sleepTimerEnabled, enabled = !isListenTogetherGuest, shape = middleShape, modifier = Modifier.size(buttonSize), textButtonColor = textButtonColor, iconButtonColor = iconButtonColor, text = if (sleepTimerEnabled) makeTimeString(sleepTimerTimeLeft) else null, iconSize = iconSize, textBackgroundColor = TextBackgroundColor, playerBackground = playerBackground, ) val shuffleModeEnabled by playerConnection.shuffleModeEnabled.collectAsState() PlayerQueueButton( icon = R.drawable.shuffle, onClick = { playerConnection.player.shuffleModeEnabled = !shuffleModeEnabled }, isActive = shuffleModeEnabled, enabled = !isListenTogetherGuest, shape = middleShape, modifier = Modifier.size(buttonSize), textButtonColor = textButtonColor, iconButtonColor = iconButtonColor, iconSize = iconSize, textBackgroundColor = TextBackgroundColor, playerBackground = playerBackground, ) PlayerQueueButton( icon = R.drawable.lyrics, onClick = { onToggleLyrics() }, isActive = showInlineLyrics, shape = middleShape, modifier = Modifier.size(buttonSize), textButtonColor = textButtonColor, iconButtonColor = iconButtonColor, iconSize = iconSize, textBackgroundColor = TextBackgroundColor, playerBackground = playerBackground, ) PlayerQueueButton( icon = when (repeatMode) { Player.REPEAT_MODE_ALL -> R.drawable.repeat Player.REPEAT_MODE_ONE -> R.drawable.repeat_one else -> R.drawable.repeat }, onClick = { playerConnection.player.toggleRepeatMode() }, isActive = repeatMode != Player.REPEAT_MODE_OFF, enabled = !isListenTogetherGuest, shape = repeatShape, modifier = Modifier.size(buttonSize), textButtonColor = textButtonColor, iconButtonColor = iconButtonColor, iconSize = iconSize, textBackgroundColor = TextBackgroundColor, playerBackground = playerBackground, ) Spacer(modifier = Modifier.weight(1f)) Box( modifier = Modifier .size(buttonSize) .clip(CircleShape) .background(textButtonColor) .clickable { menuState.show { PlayerMenu( mediaMetadata = mediaMetadata, navController = navController, playerBottomSheetState = playerBottomSheetState, onShowDetailsDialog = { mediaMetadata?.id?.let { bottomSheetPageState.show { ShowMediaInfo(it) } } }, onDismiss = menuState::dismiss, ) } }, contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(id = R.drawable.more_vert), contentDescription = null, modifier = Modifier.size(iconSize), tint = iconButtonColor, ) } } } else { // Old design Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(horizontal = 30.dp, vertical = 12.dp) .windowInsetsPadding( WindowInsets.systemBars .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), ), ) { TextButton( onClick = { state.expandSoft() }, modifier = Modifier.weight(1f), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), ) { Icon( painter = painterResource(id = R.drawable.queue_music), contentDescription = null, modifier = Modifier.size(20.dp), tint = TextBackgroundColor, ) Spacer(modifier = Modifier.width(6.dp)) Text( text = stringResource(id = R.string.queue), color = TextBackgroundColor, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.basicMarquee(), ) } } TextButton( enabled = !isListenTogetherGuest, onClick = { if (!isListenTogetherGuest) { if (sleepTimerEnabled) { playerConnection.service.sleepTimer.clear() } else { showSleepTimerDialog = true } } }, modifier = Modifier.weight(1.2f), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), ) { Icon( painter = painterResource(id = R.drawable.bedtime), contentDescription = null, modifier = Modifier.size(20.dp), tint = TextBackgroundColor, ) Spacer(modifier = Modifier.width(6.dp)) AnimatedContent( label = "sleepTimer", targetState = sleepTimerEnabled, ) { enabled -> if (enabled) { Text( text = makeTimeString(sleepTimerTimeLeft), color = TextBackgroundColor, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.basicMarquee(), ) } else { Text( text = stringResource(id = R.string.sleep_timer), color = TextBackgroundColor, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.basicMarquee(), ) } } } } TextButton( onClick = { onToggleLyrics() }, modifier = Modifier.weight(1f), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), ) { Icon( painter = painterResource(id = R.drawable.lyrics), contentDescription = null, modifier = Modifier.size(20.dp), tint = TextBackgroundColor, ) Spacer(modifier = Modifier.width(6.dp)) Text( text = stringResource(R.string.lyrics), color = TextBackgroundColor, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.basicMarquee(), ) } } } } if (showSleepTimerDialog) { ActionPromptDialog( titleBar = { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, ) { Text( text = stringResource(R.string.sleep_timer), overflow = TextOverflow.Ellipsis, maxLines = 1, style = MaterialTheme.typography.headlineSmall, ) } }, onDismiss = { showSleepTimerDialog = false }, onConfirm = { showSleepTimerDialog = false playerConnection.service.sleepTimer.start( minute = sleepTimerValue.roundToInt(), stopAfterCurrentSong = sleepTimerStopAfterCurrentSong, fadeOut = sleepTimerFadeOut, ) }, onCancel = { showSleepTimerDialog = false }, onReset = { sleepTimerValue = sleepTimerDefault }, content = { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = pluralStringResource( R.plurals.minute, sleepTimerValue.roundToInt(), sleepTimerValue.roundToInt(), ), style = MaterialTheme.typography.bodyLarge, ) Spacer(Modifier.height(16.dp)) Slider( value = sleepTimerValue, onValueChange = { sleepTimerValue = it }, valueRange = 5f..120f, steps = (120 - 5) / 5 - 1, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (isAtDefault) { Button( onClick = { coroutineScope.launch { context.dataStore.edit { settings -> settings[SleepTimerDefaultKey] = sleepTimerValue } } Toast.makeText( context, String.format(sleepTimerDefaultSetTemplate, sleepTimerValue.roundToInt()), Toast.LENGTH_SHORT, ).show() }, colors = androidx.compose.material3.ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, ), ) { Text(stringResource(R.string.set_as_default)) } } else { OutlinedButton( onClick = { coroutineScope.launch { context.dataStore.edit { settings -> settings[SleepTimerDefaultKey] = sleepTimerValue } } Toast.makeText( context, String.format(sleepTimerDefaultSetTemplate, sleepTimerValue.roundToInt()), Toast.LENGTH_SHORT, ).show() }, ) { Text(stringResource(R.string.set_as_default)) } } OutlinedButton( onClick = { showSleepTimerDialog = false playerConnection.service.sleepTimer.start( minute = -1, ) }, ) { Text(stringResource(R.string.end_of_song)) } } } }, ) } }, ) { 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() var dragInfo by remember { mutableStateOf?>(null) } val currentPlayingUid = remember(currentWindowIndex, queueWindows) { if (currentWindowIndex in queueWindows.indices) { queueWindows[currentWindowIndex].uid } else { null } } val reorderableState = rememberReorderableLazyListState( lazyListState = lazyListState, scrollThresholdPadding = WindowInsets.systemBars .add( WindowInsets( top = ListItemHeight, bottom = ListItemHeight, ), ).asPaddingValues(), ) { from, to -> val currentDragInfo = dragInfo dragInfo = if (currentDragInfo == null) { from.index to to.index } else { currentDragInfo.first to to.index } val safeFrom = (from.index - headerItems).coerceIn(0, mutableQueueWindows.lastIndex) val safeTo = (to.index - headerItems).coerceIn(0, mutableQueueWindows.lastIndex) mutableQueueWindows.move(safeFrom, safeTo) } LaunchedEffect(reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) { dragInfo?.let { (from, to) -> val safeFrom = (from - headerItems).coerceIn(0, queueWindows.lastIndex) val safeTo = (to - headerItems).coerceIn(0, queueWindows.lastIndex) 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(), ), ) } dragInfo = null } } } LaunchedEffect(queueWindows) { mutableQueueWindows.apply { clear() addAll(queueWindows) } } LaunchedEffect(mutableQueueWindows) { if (currentWindowIndex != -1) { lazyListState.scrollToItem(currentWindowIndex) } } Box( modifier = Modifier .fillMaxSize() .background(background), ) { LazyColumn( state = lazyListState, contentPadding = WindowInsets.systemBars .add( WindowInsets( top = ListItemHeight + 8.dp, bottom = ListItemHeight + 8.dp, ), ).asPaddingValues(), modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection), ) { item(key = "queue_top_spacer") { Spacer( modifier = Modifier .animateContentSize() .height(if (inSelectMode) 48.dp else 0.dp), ) } itemsIndexed( items = mutableQueueWindows, key = { _, item -> item.uid.hashCode() }, ) { index, window -> ReorderableItem( state = reorderableState, key = window.uid.hashCode(), ) { val currentItem by rememberUpdatedState(window) val isActive = window.uid == currentPlayingUid val dismissBoxState = rememberSwipeToDismissBoxState( positionalThreshold = { totalDistance -> totalDistance }, ) var processedDismiss by remember { mutableStateOf(false) } val removedSongMsg = stringResource(R.string.removed_song_from_playlist, currentItem.mediaItem.metadata?.title ?: "") val undoStr = stringResource(R.string.undo) LaunchedEffect(dismissBoxState.currentValue) { val dv = dismissBoxState.currentValue if (!processedDismiss && !isListenTogetherGuest && ( dv == SwipeToDismissBoxValue.StartToEnd || dv == SwipeToDismissBoxValue.EndToStart ) ) { processedDismiss = true playerConnection.player.removeMediaItem(currentItem.firstPeriodIndex) dismissJob?.cancel() dismissJob = coroutineScope.launch { val snackbarResult = snackbarHostState.showSnackbar( message = removedSongMsg, actionLabel = undoStr, duration = SnackbarDuration.Short, ) if (snackbarResult == SnackbarResult.ActionPerformed) { playerConnection.player.addMediaItem(currentItem.mediaItem) playerConnection.player.moveMediaItem( mutableQueueWindows.size, currentItem.firstPeriodIndex, ) } } } if (dv == SwipeToDismissBoxValue.Settled) { processedDismiss = false } } val onCheckedChange: (Boolean) -> Unit = { if (it) { selection.add(window.mediaItem.mediaId) } else { selection.remove(window.mediaItem.mediaId) } } val content: @Composable () -> Unit = { Row( horizontalArrangement = Arrangement.Center, modifier = Modifier.animateItem(), ) { MediaMetadataListItem( mediaMetadata = window.mediaItem.metadata!!, isSelected = false, isActive = isActive, isPlaying = isPlaying && isActive, trailingContent = { if (inSelectMode) { Checkbox( checked = window.mediaItem.mediaId in selection, onCheckedChange = onCheckedChange, ) } else { if (!isListenTogetherGuest) { IconButton( onClick = { menuState.show { QueueMenu( mediaMetadata = window.mediaItem.metadata!!, navController = navController, playerBottomSheetState = playerBottomSheetState, onShowDetailsDialog = { window.mediaItem.mediaId.let { bottomSheetPageState.show { ShowMediaInfo(it) } } }, onDismiss = menuState::dismiss, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, ) } } if (!locked && !isListenTogetherGuest) { IconButton( onClick = { }, modifier = Modifier.draggableHandle(), ) { Icon( painter = painterResource(R.drawable.drag_handle), contentDescription = null, ) } } } }, modifier = Modifier .fillMaxWidth() .background(background) .combinedClickable( onClick = { if (inSelectMode) { onCheckedChange(window.mediaItem.mediaId !in selection) } else if (!isListenTogetherGuest) { if (index == currentWindowIndex) { if (isCasting) { if (castIsPlaying) { castHandler?.pause() } else { castHandler?.play() } } else { playerConnection.togglePlayPause() } } else { if (isCasting) { val mediaId = window.mediaItem.mediaId val navigated = castHandler?.navigateToMediaIfInQueue(mediaId) ?: false if (!navigated) { playerConnection.player.seekToDefaultPosition(window.firstPeriodIndex) } } else { playerConnection.player.seekToDefaultPosition( window.firstPeriodIndex, ) playerConnection.player.playWhenReady = true } } } }, onLongClick = { if (!inSelectMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) } }, ), ) } } if (locked) { content() } else { SwipeToDismissBox( state = dismissBoxState, backgroundContent = {}, ) { content() } } } } if (automix.isNotEmpty()) { item(key = "automix_divider") { HorizontalDivider( modifier = Modifier .padding(vertical = 8.dp, horizontal = 4.dp) .animateItem(), ) Text( text = stringResource(R.string.similar_content), modifier = Modifier.padding(start = 16.dp), ) } itemsIndexed( items = automix, key = { _, it -> it.mediaId }, ) { index, item -> Row( horizontalArrangement = Arrangement.Center, ) { MediaMetadataListItem( mediaMetadata = item.metadata!!, trailingContent = { if (!isListenTogetherGuest) { 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 { QueueMenu( mediaMetadata = item.metadata!!, navController = navController, playerBottomSheetState = playerBottomSheetState, onShowDetailsDialog = { item.mediaId.let { bottomSheetPageState.show { ShowMediaInfo(it) } } }, onDismiss = menuState::dismiss, ) } }, ).animateItem(), ) } } } } } Column( modifier = Modifier .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, ) { } .background( if (pureBlack) { Color.Black } else { 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 = !inSelectMode, 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 = inSelectMode, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { val selectedSongs = remember(selection.toList(), mutableQueueWindows) { mutableQueueWindows .filter { it.mediaItem.mediaId in selection } .mapNotNull { it.mediaItem.metadata } } val selectedItems = remember(selection.toList(), mutableQueueWindows) { mutableQueueWindows.filter { it.mediaItem.mediaId in selection } } val count = selection.size Row( modifier = Modifier .height(48.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton( onClick = onExitSelectionMode, ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, ) } Text( text = pluralStringResource(R.plurals.n_selected, count, count), modifier = Modifier.weight(1f), ) Checkbox( checked = count == mutableQueueWindows.size && count > 0, onCheckedChange = { if (count == mutableQueueWindows.size) { selection.clear() } else { selection.clear() mutableQueueWindows.forEach { selection.add(it.mediaItem.mediaId) } } }, ) IconButton( enabled = count > 0, onClick = { menuState.show { SelectionMediaMetadataMenu( songSelection = selectedSongs, onDismiss = menuState::dismiss, clearAction = onExitSelectionMode, currentItems = selectedItems, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, tint = LocalContentColor.current, ) } } } if (pureBlack) { HorizontalDivider() } } val shuffleModeEnabled by playerConnection.shuffleModeEnabled.collectAsState() Box( modifier = Modifier .background( if (pureBlack) { Color.Black } else { MaterialTheme.colorScheme .secondaryContainer .copy(alpha = 0.90f) }, ).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( enabled = !isListenTogetherGuest, 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 } }, ) { val baseAlpha = if (shuffleModeEnabled) 1f else 0.5f val finalAlpha = if (!isListenTogetherGuest) baseAlpha else 0.3f Icon( painter = painterResource(R.drawable.shuffle), contentDescription = null, modifier = Modifier.alpha(finalAlpha), ) } Icon( painter = painterResource(R.drawable.expand_more), contentDescription = null, modifier = Modifier.align(Alignment.Center), ) IconButton( enabled = !isListenTogetherGuest, modifier = Modifier.align(Alignment.CenterEnd), onClick = playerConnection.player::toggleRepeatMode, ) { val baseAlpha = if (repeatMode == Player.REPEAT_MODE_OFF) 0.5f else 1f val finalAlpha = if (!isListenTogetherGuest) baseAlpha else 0.3f 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(finalAlpha), ) } } SnackbarHost( hostState = snackbarHostState, modifier = Modifier .padding( bottom = ListItemHeight + WindowInsets.systemBars .asPaddingValues() .calculateBottomPadding(), ).align(Alignment.BottomCenter), ) } } @Composable private fun PlayerQueueButton( icon: Int, onClick: () -> Unit, isActive: Boolean, enabled: Boolean = true, shape: RoundedCornerShape, modifier: Modifier = Modifier, text: String? = null, textButtonColor: Color, iconButtonColor: Color, iconSize: androidx.compose.ui.unit.Dp, textBackgroundColor: Color, playerBackground: PlayerBackgroundStyle, ) { val buttonModifier = Modifier .clip(shape) .clickable(enabled = enabled, onClick = onClick) val alphaFactor = if (enabled) 1f else 0.35f val appliedModifier = if (isActive) { modifier.then(buttonModifier.background(textButtonColor)).alpha(alphaFactor) } else { modifier .then( buttonModifier.border( width = 1.dp, color = textButtonColor.copy(alpha = 0.3f), shape = shape, ), ).alpha(alphaFactor) } Box( modifier = appliedModifier, contentAlignment = Alignment.Center, ) { if (text != null) { Text( text = text, color = iconButtonColor.copy(alpha = if (enabled) 1f else 0.6f), fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .basicMarquee(), ) } else { val baseTint = if (isActive) { iconButtonColor } else { when (playerBackground) { PlayerBackgroundStyle.BLUR, PlayerBackgroundStyle.GRADIENT -> { Color.White } PlayerBackgroundStyle.DEFAULT -> { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) } } } val finalTint = if (enabled) baseTint else baseTint.copy(alpha = 0.5f) Icon( painter = painterResource(id = icon), contentDescription = null, modifier = Modifier.size(iconSize), tint = finalTint, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/player/Thumbnail.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.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.statusBarsPadding import androidx.compose.foundation.layout.width 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.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.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.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer 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.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.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import coil3.compose.AsyncImage import coil3.request.CachePolicy import coil3.request.ImageRequest import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.CropAlbumArtKey import com.metrolist.music.constants.HidePlayerThumbnailKey import com.metrolist.music.constants.PlayerBackgroundStyle import com.metrolist.music.constants.PlayerBackgroundStyleKey import com.metrolist.music.constants.PlayerHorizontalPadding import com.metrolist.music.constants.SeekExtraSeconds import com.metrolist.music.constants.SwipeThumbnailKey import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.listentogether.RoomRole import com.metrolist.music.ui.component.CastButton import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.delay /** * Pre-calculated thumbnail dimensions to avoid repeated calculations during recomposition. * All values are computed once and cached. */ @Immutable data class ThumbnailDimensions( val itemWidth: Dp, val containerSize: Dp, val thumbnailSize: Dp, val cornerRadius: Dp ) /** * Cached media items data to prevent recalculation on every recomposition. */ @Immutable data class MediaItemsData( val items: List, val currentIndex: Int ) /** * Calculate thumbnail dimensions once based on container size. * This function is marked as @Stable to indicate it produces stable results. * In landscape mode, uses the smaller dimension (height) to ensure square thumbnail fits. */ @Stable private fun calculateThumbnailDimensions( containerWidth: Dp, containerHeight: Dp = containerWidth, horizontalPadding: Dp = PlayerHorizontalPadding, cornerRadius: Dp = ThumbnailCornerRadius, isLandscape: Boolean = false ): ThumbnailDimensions { // In landscape, use height as the constraining dimension for a square thumbnail val effectiveSize = if (isLandscape) { minOf(containerWidth, containerHeight) - (horizontalPadding * 2) } else { containerWidth - (horizontalPadding * 2) } return ThumbnailDimensions( itemWidth = containerWidth, containerSize = containerWidth, thumbnailSize = effectiveSize, cornerRadius = cornerRadius * 2 ) } /** * Get media items for the thumbnail carousel. * Calculates previous, current, and next items based on shuffle mode. */ @Stable private fun getMediaItems( player: Player, swipeThumbnail: Boolean ): MediaItemsData { val timeline = player.currentTimeline val currentIndex = player.currentMediaItemIndex val shuffleModeEnabled = player.shuffleModeEnabled val currentMediaItem = try { player.currentMediaItem } catch (e: Exception) { null } val previousMediaItem = if (swipeThumbnail && !timeline.isEmpty) { val previousIndex = timeline.getPreviousWindowIndex( currentIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled ) if (previousIndex != C.INDEX_UNSET) { try { player.getMediaItemAt(previousIndex) } catch (e: Exception) { null } } else null } else null val nextMediaItem = if (swipeThumbnail && !timeline.isEmpty) { val nextIndex = timeline.getNextWindowIndex( currentIndex, Player.REPEAT_MODE_OFF, shuffleModeEnabled ) if (nextIndex != C.INDEX_UNSET) { try { player.getMediaItemAt(nextIndex) } catch (e: Exception) { null } } else null } else null val items = listOfNotNull(previousMediaItem, currentMediaItem, nextMediaItem) val currentMediaIndex = items.indexOf(currentMediaItem) return MediaItemsData(items, currentMediaIndex) } /** * Get text color based on player background style. * Computed once per background style change. */ @Stable @Composable private fun getTextColor(playerBackground: PlayerBackgroundStyle): Color { return when (playerBackground) { PlayerBackgroundStyle.DEFAULT -> MaterialTheme.colorScheme.onBackground PlayerBackgroundStyle.BLUR -> Color.White PlayerBackgroundStyle.GRADIENT -> Color.White } } @OptIn(ExperimentalFoundationApi::class) @Composable fun Thumbnail( sliderPositionProvider: () -> Long?, modifier: Modifier = Modifier, isPlayerExpanded: () -> Boolean = { true }, isLandscape: Boolean = false, isListenTogetherGuest: Boolean = false, ) { val playerConnection = LocalPlayerConnection.current ?: return val context = LocalContext.current val layoutDirection = LocalLayoutDirection.current // Collect states val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val error by playerConnection.error.collectAsState() val queueTitle by playerConnection.queueTitle.collectAsState() val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState() val canSkipNext by playerConnection.canSkipNext.collectAsState() // Preferences - computed once // Disable swipe for Listen Together guests val swipeThumbnailPref by rememberPreference(SwipeThumbnailKey, true) val swipeThumbnail = swipeThumbnailPref && !isListenTogetherGuest val hidePlayerThumbnail by rememberPreference(HidePlayerThumbnailKey, false) val cropAlbumArt by rememberPreference(CropAlbumArtKey, false) val playerBackground by rememberEnumPreference( key = PlayerBackgroundStyleKey, defaultValue = PlayerBackgroundStyle.DEFAULT ) // Pre-calculate text color based on background style val textBackgroundColor = getTextColor(playerBackground) // Grid state val thumbnailLazyGridState = rememberLazyGridState() // Calculate media items data - memoized val mediaItemsData by remember( playerConnection.player.currentMediaItemIndex, playerConnection.player.shuffleModeEnabled, swipeThumbnail, mediaMetadata ) { derivedStateOf { getMediaItems(playerConnection.player, swipeThumbnail) } } val mediaItems = mediaItemsData.items val currentMediaIndex = mediaItemsData.currentIndex // Snap behavior - created once per grid state val thumbnailSnapLayoutInfoProvider = remember(thumbnailLazyGridState) { ThumbnailSnapLayoutInfoProvider( lazyGridState = thumbnailLazyGridState, positionInLayout = { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) }, velocityThreshold = 500f ) } // Current item tracking - derived state for efficiency val currentItem by remember { derivedStateOf { thumbnailLazyGridState.firstVisibleItemIndex } } val itemScrollOffset by remember { derivedStateOf { thumbnailLazyGridState.firstVisibleItemScrollOffset } } // Handle swipe to change song 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() } } // Update position when song changes 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 = mediaItemsData.currentIndex if (index >= 0 && index != currentItem) { thumbnailLazyGridState.scrollToItem(index) } } // Seek effect state var showSeekEffect by remember { mutableStateOf(false) } var seekDirection by remember { mutableStateOf("") } Box( modifier = modifier .graphicsLayer { // Use hardware layer for entire Thumbnail to ensure smooth 120Hz animations compositingStrategy = CompositingStrategy.Offscreen } ) { // Error view 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, ) } } // Main thumbnail view AnimatedVisibility( visible = error == null, enter = fadeIn(), exit = fadeOut(), modifier = Modifier .fillMaxSize() .then(if (!isLandscape) Modifier.statusBarsPadding() else Modifier), ) { Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = if (isLandscape) Arrangement.Center else Arrangement.Top ) { // Now Playing header - hide in landscape mode if (!isLandscape) { ThumbnailHeader( queueTitle = queueTitle, albumTitle = mediaMetadata?.album?.title, textColor = textBackgroundColor ) } // Thumbnail content BoxWithConstraints( contentAlignment = Alignment.Center, modifier = if (isLandscape) { Modifier.weight(1f, false) } else { Modifier.fillMaxSize() } ) { // Calculate dimensions once per size change, considering landscape mode val dimensions = remember(maxWidth, maxHeight, isLandscape) { calculateThumbnailDimensions( containerWidth = maxWidth, containerHeight = maxHeight, isLandscape = isLandscape ) } // Remember the onSeek callback to prevent recomposition val onSeekCallback = remember { { direction: String, showEffect: Boolean -> seekDirection = direction showSeekEffect = showEffect } } // Derive scroll enabled state to prevent unnecessary recomposition val isScrollEnabled by remember(swipeThumbnail) { derivedStateOf { swipeThumbnail && isPlayerExpanded() } } LazyHorizontalGrid( state = thumbnailLazyGridState, rows = GridCells.Fixed(1), flingBehavior = rememberSnapFlingBehavior(thumbnailSnapLayoutInfoProvider), userScrollEnabled = isScrollEnabled, modifier = if (isLandscape) { Modifier.size(dimensions.thumbnailSize + (PlayerHorizontalPadding * 2)) } else { Modifier.fillMaxSize() } ) { items( items = mediaItems, key = { item -> item.mediaId.ifEmpty { "unknown_${item.hashCode()}" } } ) { item -> ThumbnailItem( item = item, dimensions = dimensions, hidePlayerThumbnail = hidePlayerThumbnail, cropAlbumArt = cropAlbumArt, textBackgroundColor = textBackgroundColor, layoutDirection = layoutDirection, onSeek = onSeekCallback, playerConnection = playerConnection, context = context, isLandscape = isLandscape, isListenTogetherGuest = isListenTogetherGuest, currentMediaId = mediaMetadata?.id, currentMediaThumbnail = mediaMetadata?.thumbnailUrl ) } } } } } // Seek effect LaunchedEffect(showSeekEffect) { if (showSeekEffect) { delay(1000) showSeekEffect = false } } AnimatedVisibility( visible = showSeekEffect, enter = fadeIn(), exit = fadeOut(), modifier = Modifier.align(Alignment.Center) ) { SeekEffectOverlay(seekDirection = seekDirection) } } } /** * Header component showing "Now Playing" and queue/album title. */ @Composable private fun ThumbnailHeader( queueTitle: String?, albumTitle: String?, textColor: Color, modifier: Modifier = Modifier ) { val listenTogetherManager = LocalListenTogetherManager.current val listenTogetherRoleState = listenTogetherManager?.role?.collectAsState(initial = RoomRole.NONE) val isListenTogetherGuest = listenTogetherRoleState?.value == RoomRole.GUEST Box( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .align(Alignment.Center) .padding(horizontal = 48.dp) ) { // Listen Together indicator if (listenTogetherRoleState?.value != RoomRole.NONE) { Text( text = if (listenTogetherRoleState?.value == RoomRole.HOST) "Hosting Listen Together" else "Listening Together", style = MaterialTheme.typography.titleMedium, color = textColor ) } else { Text( text = stringResource(R.string.now_playing), style = MaterialTheme.typography.titleMedium, color = textColor ) } val playingFrom = queueTitle ?: albumTitle if (!playingFrom.isNullOrBlank()) { Spacer(modifier = Modifier.height(4.dp)) Text( text = playingFrom, style = MaterialTheme.typography.titleMedium, color = textColor.copy(alpha = 0.8f), maxLines = 1, modifier = Modifier.basicMarquee() ) } } } } /** * Individual thumbnail item in the carousel. */ @Composable private fun ThumbnailItem( item: MediaItem, dimensions: ThumbnailDimensions, hidePlayerThumbnail: Boolean, cropAlbumArt: Boolean, textBackgroundColor: Color, layoutDirection: LayoutDirection, onSeek: (String, Boolean) -> Unit, playerConnection: com.metrolist.music.playback.PlayerConnection, context: android.content.Context, isLandscape: Boolean = false, isListenTogetherGuest: Boolean = false, currentMediaId: String? = null, currentMediaThumbnail: String? = null, modifier: Modifier = Modifier, ) { val incrementalSeekSkipEnabled by rememberPreference(SeekExtraSeconds, defaultValue = false) var skipMultiplier by remember { mutableIntStateOf(1) } var lastTapTime by remember { mutableLongStateOf(0L) } Box( modifier = modifier .then( if (isLandscape) { Modifier.size(dimensions.thumbnailSize + (PlayerHorizontalPadding * 2)) } else { Modifier .width(dimensions.itemWidth) .fillMaxSize() } ) .padding(horizontal = PlayerHorizontalPadding) .graphicsLayer { // Render entire thumbnail item on separate hardware layer for smooth animations compositingStrategy = CompositingStrategy.Offscreen } .pointerInput(Unit) { detectTapGestures( onDoubleTap = { offset -> if (isListenTogetherGuest) return@detectTapGestures val currentPosition = playerConnection.player.currentPosition val duration = playerConnection.player.duration val now = System.currentTimeMillis() if (incrementalSeekSkipEnabled && now - lastTapTime < 1000) { skipMultiplier++ } else { skipMultiplier = 1 } lastTapTime = now val skipAmount = 5000 * skipMultiplier val isLeftSide = (layoutDirection == LayoutDirection.Ltr && offset.x < size.width / 2) || (layoutDirection == LayoutDirection.Rtl && offset.x > size.width / 2) if (isLeftSide) { playerConnection.player.seekTo((currentPosition - skipAmount).coerceAtLeast(0)) onSeek(context.getString(R.string.seek_backward_dynamic, skipAmount / 1000), true) } else { playerConnection.player.seekTo((currentPosition + skipAmount).coerceAtMost(duration)) onSeek(context.getString(R.string.seek_forward_dynamic, skipAmount / 1000), true) } } ) }, contentAlignment = Alignment.Center ) { Box( modifier = Modifier .size(dimensions.thumbnailSize) .clip(RoundedCornerShape(dimensions.cornerRadius)) ) { if (hidePlayerThumbnail) { HiddenThumbnailPlaceholder(textBackgroundColor = textBackgroundColor) } else { val artworkUriToUse = if (item.mediaId == currentMediaId && !currentMediaThumbnail.isNullOrBlank()) { currentMediaThumbnail } else { item.mediaMetadata.artworkUri?.toString() } ThumbnailImage( artworkUri = artworkUriToUse, cropArtwork = cropAlbumArt ) } // Cast button at top-right corner of thumbnail CastButton( modifier = Modifier .align(Alignment.TopEnd) .padding(8.dp), tintColor = textBackgroundColor ) } } } /** * Placeholder shown when thumbnail is hidden. */ @Composable private fun HiddenThumbnailPlaceholder( textBackgroundColor: Color, modifier: Modifier = Modifier ) { Box( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(R.drawable.small_icon), contentDescription = stringResource(R.string.hide_player_thumbnail), tint = textBackgroundColor.copy(alpha = 0.7f), modifier = Modifier.size(120.dp) ) } } /** * Actual thumbnail image with caching and hardware layer rendering. */ @Composable private fun ThumbnailImage( artworkUri: String?, cropArtwork: Boolean, modifier: Modifier = Modifier ) { Box( modifier = modifier .fillMaxSize() .graphicsLayer { // Use offscreen compositing for hardware acceleration during animations compositingStrategy = CompositingStrategy.Offscreen } .background(MaterialTheme.colorScheme.surfaceVariant) ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(artworkUri) .memoryCachePolicy(CachePolicy.ENABLED) .diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .build(), contentDescription = null, contentScale = if (cropArtwork) ContentScale.Crop else ContentScale.Fit, modifier = Modifier.fillMaxSize() ) } } /** * Seek effect overlay showing seek direction. */ @Composable private fun SeekEffectOverlay( seekDirection: String, modifier: Modifier = Modifier ) { 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) ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/player/ThumbnailSnapUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors * * Snap utilities for Thumbnail grid navigation * Copyright (C) OuterTune Project - Custom SnapLayoutInfoProvider idea belongs to OuterTune */ package com.metrolist.music.ui.player 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 import kotlin.math.abs /** * Custom SnapLayoutInfoProvider for horizontal grid snapping behavior. * Provides smooth snapping to items based on velocity and position. * * @param lazyGridState The state of the LazyHorizontalGrid * @param positionInLayout Function to calculate the desired snap position * @param velocityThreshold Minimum velocity required to trigger directional snap */ @ExperimentalFoundationApi fun ThumbnailSnapLayoutInfoProvider( 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() // Only snap when velocity exceeds threshold if (abs(velocity) < velocityThreshold) { return if (abs(bounds.start) < abs(bounds.endInclusive)) { bounds.start } else { bounds.endInclusive } } return when { velocity < 0 -> bounds.start velocity > 0 -> bounds.endInclusive else -> 0f } } private 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) } } /** * Calculates the distance from an item's current position to its desired snap position. * * @param layoutInfo The layout information of the grid * @param item The item to calculate distance for * @param positionInLayout Function to determine the desired position * @return The distance in pixels to the desired snap position */ 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 } /** * Extension property to get the viewport size along the scroll axis. */ val LazyGridLayoutInfo.singleAxisViewportSize: Int get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/AccountScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens 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.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.fillMaxWidth 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.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.itemsIndexed 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.MaterialTheme 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.rememberCoroutineScope 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.LocalHapticFeedback 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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.db.entities.PodcastEntity import com.metrolist.music.ui.component.shimmer.GridItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ListItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.menu.YouTubeArtistMenu import com.metrolist.music.ui.menu.YouTubePlaylistMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.viewmodels.AccountContentType import com.metrolist.music.viewmodels.AccountViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun AccountScreen( navController: NavController, 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() val sePlaylist by viewModel.sePlaylist.collectAsState() val rdpnPlaylist by viewModel.rdpnPlaylist.collectAsState() val podcastPlaylists by viewModel.podcastPlaylists.collectAsState() val podcastChannels by viewModel.podcastChannels.collectAsState() val selectedContentType by viewModel.selectedContentType.collectAsState() val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) LazyVerticalGrid( columns = GridCells.Adaptive(minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp), contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { item(span = { GridItemSpan(maxLineSpan) }) { ChipsRow( chips = listOf( AccountContentType.PLAYLISTS to stringResource(R.string.filter_playlists), AccountContentType.ALBUMS to stringResource(R.string.filter_albums), AccountContentType.ARTISTS to stringResource(R.string.filter_artists), AccountContentType.PODCASTS to stringResource(R.string.filter_podcasts), ), currentValue = selectedContentType, onValueUpdate = { viewModel.setSelectedContentType(it) }, ) } when (selectedContentType) { AccountContentType.PLAYLISTS -> { items( items = playlists.orEmpty().distinctBy { it.id }, 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, ) } }, ), ) } if (playlists == null) { items(8) { ShimmerHost { GridItemPlaceHolder(fillMaxWidth = true) } } } } AccountContentType.ALBUMS -> { items( items = albums.orEmpty().distinctBy { it.id }, 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 ) } } ) ) } if (albums == null) { items(8) { ShimmerHost { GridItemPlaceHolder(fillMaxWidth = true) } } } } AccountContentType.ARTISTS -> { items( items = artists.orEmpty().distinctBy { it.id }, 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 (artists == null) { items(8) { ShimmerHost { GridItemPlaceHolder(fillMaxWidth = true) } } } } AccountContentType.PODCASTS -> { // Show RDPN "New Episodes" playlist if available rdpnPlaylist?.let { rdpn -> item( key = "rdpn_playlist", span = { GridItemSpan(maxLineSpan) }, ) { SePlaylistAccountItem( thumbnailUrl = rdpn.thumbnail, title = stringResource(R.string.new_episodes), subtitle = stringResource(R.string.auto_playlist), onClick = { navController.navigate("online_playlist/RDPN") }, ) } } // Show SE "Episodes for Later" playlist if available sePlaylist?.let { se -> item( key = "se_playlist", span = { GridItemSpan(maxLineSpan) }, ) { SePlaylistAccountItem( thumbnailUrl = se.thumbnail, title = stringResource(R.string.episodes_for_later), subtitle = stringResource(R.string.auto_playlist), onClick = { navController.navigate("online_playlist/SE") }, ) } } // Subscribed podcast shows if (podcastPlaylists.isNotEmpty()) { item( key = "podcasts_header", span = { GridItemSpan(maxLineSpan) }, ) { Text( text = stringResource(R.string.filter_podcasts), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) } itemsIndexed( items = podcastPlaylists, key = { _, item -> "podcast_${item.id}" }, span = { _, _ -> GridItemSpan(maxLineSpan) }, ) { _, podcast -> PodcastAccountItem( thumbnailUrl = podcast.thumbnailUrl, title = podcast.title, subtitle = podcast.author, onClick = { navController.navigate("online_podcast/${podcast.id}") }, ) } } // Podcast channels if (podcastChannels.isNotEmpty()) { item( key = "channels_header", span = { GridItemSpan(maxLineSpan) }, ) { Text( text = stringResource(R.string.filter_channels), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) } itemsIndexed( items = podcastChannels, key = { _, item -> "channel_${item.id}" }, span = { _, _ -> GridItemSpan(maxLineSpan) }, ) { _, channel -> PodcastChannelAccountItem( thumbnailUrl = channel.thumbnail, name = channel.title, onClick = { navController.navigate("artist/${channel.id}") }, ) } } if (rdpnPlaylist == null && sePlaylist == null && podcastPlaylists.isEmpty() && podcastChannels.isEmpty()) { items(4, span = { GridItemSpan(maxLineSpan) }) { ShimmerHost { ListItemPlaceHolder() } } } } } } TopAppBar( title = { Text(stringResource(R.string.account)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) } @Composable private fun SePlaylistAccountItem( thumbnailUrl: String?, title: String, subtitle: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), ) { Box( modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center, ) { if (thumbnailUrl != null) { AsyncImage( model = thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(8.dp)), ) } else { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(28.dp), ) } } Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } Icon( painter = painterResource(R.drawable.navigate_next), contentDescription = null, ) } } @Composable private fun PodcastAccountItem( thumbnailUrl: String?, title: String, subtitle: String?, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 8.dp), ) { Box( modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center, ) { if (thumbnailUrl != null) { AsyncImage( model = thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(8.dp)), ) } else { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(28.dp), ) } } Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (!subtitle.isNullOrBlank()) { Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } } @Composable private fun PodcastChannelAccountItem( thumbnailUrl: String?, name: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 8.dp), ) { AsyncImage( model = thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(56.dp) .clip(CircleShape), ) Spacer(Modifier.width(12.dp)) Text( text = name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/AlbumScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import androidx.activity.compose.BackHandler 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.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Checkbox import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 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.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.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow 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.LinkAnnotation 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.withLink import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.util.fastForEachReversed import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.media3.exoplayer.offline.Download import androidx.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.db.entities.Album import com.metrolist.music.playback.queues.LocalAlbumRadio import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.NavigationTitle import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.ui.menu.AlbumMenu import com.metrolist.music.ui.menu.SelectionSongMenu import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.AlbumViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun AlbumScreen( navController: NavController, 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 listenTogetherManager = LocalListenTogetherManager.current val isListenTogetherGuest = listenTogetherManager?.let { it.isInRoom && !it.isHost } ?: false val scope = rememberCoroutineScope() val isPlaying by playerConnection.isEffectivelyPlaying.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 hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) val hideVideoSongs by rememberPreference(key = HideVideoSongsKey, defaultValue = false) val filteredSongs = remember(albumWithSongs, hideExplicit, hideVideoSongs) { var songs = albumWithSongs?.songs ?: emptyList() if (hideExplicit) { songs = songs.filter { !it.song.explicit } } if (hideVideoSongs) { songs = songs.filter { !it.song.isVideo } } songs } var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, String>( save = { it.toList() }, restore = { it.toMutableStateList() }, ), ) { mutableStateListOf() } val onExitSelectionMode = { inSelectMode = false selection.clear() } if (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } LaunchedEffect(filteredSongs) { selection.fastForEachReversed { songId -> if (filteredSongs.find { it.id == songId } == null) { selection.remove(songId) } } } val downloadUtil = LocalDownloadUtil.current var downloadState by remember { mutableIntStateOf(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(key = "album_header") { Column( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp, bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { // Album Thumbnail - Large centered with shadow Surface( modifier = Modifier .size(240.dp) .shadow( elevation = 24.dp, shape = RoundedCornerShape(3.dp), spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), ), shape = RoundedCornerShape(3.dp), ) { AsyncImage( model = albumWithSongs.album.thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) } Spacer(modifier = Modifier.height(20.dp)) // Album Name Text( text = albumWithSongs.album.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 32.dp), ) Spacer(modifier = Modifier.height(8.dp)) // Artist Names - Below the album name Text( buildAnnotatedString { withStyle( style = MaterialTheme.typography.titleMedium .copy( fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.onBackground, ).toSpanStyle(), ) { albumWithSongs.artists.fastForEachIndexed { index, artist -> val link = LinkAnnotation.Clickable(artist.id) { navController.navigate("artist/${artist.id}") } withLink(link) { append(artist.name) } if (index != albumWithSongs.artists.lastIndex) { append(", ") } } } }, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(12.dp)) // Metadata - Year first, then song count • duration val totalDuration = albumWithSongs.songs.sumOf { it.song.duration } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { // Year if (albumWithSongs.album.year != null) { Text( text = albumWithSongs.album.year.toString(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), ) } // Song Count • Duration Text( text = buildString { append( pluralStringResource( R.plurals.n_song, albumWithSongs.songs.size, albumWithSongs.songs.size, ), ) if (totalDuration > 0) { append(" • ") append(makeTimeString(totalDuration * 1000L)) } }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), ) } Spacer(modifier = Modifier.height(24.dp)) // Action Buttons Row Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { // Like Button - Smaller secondary button Surface( onClick = { database.query { update(albumWithSongs.album.toggleLike()) } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp), ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { 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 { MaterialTheme.colorScheme.onSurfaceVariant }, modifier = Modifier.size(24.dp), ) } } // Play Button - Larger primary circular button Surface( onClick = { if (!isListenTogetherGuest) { playerConnection.service.getAutomix(playlistId) playerConnection.playQueue( LocalAlbumRadio(albumWithSongs), ) } }, color = MaterialTheme.colorScheme.primary, shape = CircleShape, modifier = Modifier.size(72.dp), ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Icon( painter = painterResource(R.drawable.play), contentDescription = stringResource(R.string.play), tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(32.dp), ) } } // Menu Button - Smaller secondary button Surface( onClick = { menuState.show { AlbumMenu( originalAlbum = Album( albumWithSongs.album, albumWithSongs.artists, ), navController = navController, onDismiss = menuState::dismiss, ) } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp), ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, modifier = Modifier.size(24.dp), ) } } } } } if (filteredSongs.isNotEmpty()) { itemsIndexed( items = filteredSongs, key = { _, song -> song.id }, ) { index, song -> val onCheckedChange: (Boolean) -> Unit = { if (it) { selection.add(song.id) } else { selection.remove(song.id) } } SongListItem( song = song, albumIndex = index + 1, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, trailingContent = { if (inSelectMode) { Checkbox( checked = song.id in selection, onCheckedChange = onCheckedChange, ) } else { IconButton( onClick = { menuState.show { SongMenu( originalSong = song, navController = navController, onDismiss = menuState::dismiss, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, ) } } }, modifier = Modifier .fillMaxWidth() .animateItem() .combinedClickable( onClick = { if (inSelectMode) { onCheckedChange(song.id !in selection) } else if (!isListenTogetherGuest) { if (song.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.service.getAutomix(playlistId) playerConnection.playQueue( LocalAlbumRadio(albumWithSongs, startIndex = index), ) } } }, onLongClick = { if (!inSelectMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) } }, ), ) } } if (otherVersions.isNotEmpty()) { item(key = "other_versions_title") { NavigationTitle( title = stringResource(R.string.other_versions), modifier = Modifier.animateItem(), ) } item(key = "other_versions_list") { LazyRow( contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues(), ) { items( items = otherVersions.distinctBy { it.id }, 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(key = "loading") { Box( modifier = Modifier .fillMaxWidth() .padding(32.dp), contentAlignment = Alignment.Center, ) { ContainedLoadingIndicator() } } } } TopAppBar( title = { if (inSelectMode) { Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) } else { Text( text = albumWithSongs?.album?.title.orEmpty(), style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } }, navigationIcon = { if (inSelectMode) { IconButton(onClick = onExitSelectionMode) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, ) } } else { IconButton( onClick = { navController.navigateUp() }, onLongClick = { navController.backToMain() }, ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = null, ) } } }, actions = { if (inSelectMode) { Checkbox( checked = selection.size == filteredSongs.size && selection.isNotEmpty(), onCheckedChange = { if (selection.size == filteredSongs.size) { selection.clear() } else { selection.clear() selection.addAll(filteredSongs.map { it.id }) } }, ) IconButton( enabled = selection.isNotEmpty(), onClick = { menuState.show { SelectionSongMenu( songSelection = selection.mapNotNull { songId -> filteredSongs.find { it.id == songId } }, onDismiss = menuState::dismiss, clearAction = onExitSelectionMode, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, ) } } }, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/BrowseScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.res.painterResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.ui.component.shimmer.GridItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.menu.YouTubeArtistMenu import com.metrolist.music.ui.menu.YouTubePlaylistMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.viewmodels.BrowseViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun BrowseScreen( navController: NavController, browseId: String?, viewModel: BrowseViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val title by viewModel.title.collectAsState() val items by viewModel.items.collectAsState() val coroutineScope = rememberCoroutineScope() val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) LazyVerticalGrid( columns = GridCells.Adaptive(minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp), contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { items?.let { items -> items( items = items.distinctBy { it.id }, key = { it.id } ) { item -> YouTubeGridItem( item = item, isPlaying = isPlaying, fillMaxWidth = true, coroutineScope = coroutineScope, modifier = Modifier .combinedClickable( onClick = { when (item) { is AlbumItem -> navController.navigate("album/${item.id}") is PlaylistItem -> navController.navigate("online_playlist/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") else -> { // Do nothing } } }, onLongClick = { menuState.show { when (item) { is AlbumItem -> YouTubeAlbumMenu( albumItem = item, navController = navController, onDismiss = menuState::dismiss ) is PlaylistItem -> { YouTubePlaylistMenu( playlist = item, coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) } is ArtistItem -> { YouTubeArtistMenu( artist = item, onDismiss = menuState::dismiss ) } else -> { // Do nothing } } } } ) ) } if (items.isEmpty()) { items(8) { ShimmerHost { GridItemPlaceHolder(fillMaxWidth = true) } } } } } TopAppBar( title = { Text(title ?: "") }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null ) } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/ChartsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.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.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.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.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.Scaffold import androidx.compose.material3.Text 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.remember import androidx.compose.runtime.rememberCoroutineScope 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.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.NavigationTitle import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.component.shimmer.GridItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.component.shimmer.TextPlaceholder import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.SnapLayoutInfoProvider import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.viewmodels.ChartsViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ChartsScreen( navController: NavController, viewModel: ChartsViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val chartsPage by viewModel.chartsPage.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val lazyListState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() LaunchedEffect(Unit) { if (chartsPage == null) { viewModel.loadCharts() } } Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.charts)) }, navigationIcon = { IconButton( onClick = { navController.navigateUp() }, onLongClick = { navController.backToMain() } ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) } ) { paddingValues -> BoxWithConstraints( modifier = Modifier .fillMaxSize() .padding(paddingValues), ) { if (isLoading || chartsPage == null) { ShimmerHost( modifier = Modifier.fillMaxSize(), ) { Column( modifier = Modifier.padding(16.dp) ) { TextPlaceholder( height = 36.dp, modifier = Modifier .padding(12.dp) .fillMaxWidth(0.5f), ) BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor LazyHorizontalGrid( rows = GridCells.Fixed(4), contentPadding = PaddingValues(start = 4.dp), modifier = Modifier .fillMaxWidth() .height(ListItemHeight * 4), ) { items(4) { Row( modifier = Modifier .width(horizontalLazyGridItemWidth) .padding(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier .size(ListItemHeight - 16.dp) .clip(RoundedCornerShape(4.dp)) .background(MaterialTheme.colorScheme.onSurface), ) Spacer(modifier = Modifier.width(8.dp)) Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center, ) { Box( modifier = Modifier .height(16.dp) .width(120.dp) .background(MaterialTheme.colorScheme.onSurface), ) Spacer(modifier = Modifier.height(8.dp)) Box( modifier = Modifier .height(12.dp) .width(80.dp) .background(MaterialTheme.colorScheme.onSurface), ) } } } } } TextPlaceholder( height = 36.dp, modifier = Modifier .padding(vertical = 12.dp, horizontal = 12.dp) .width(250.dp), ) Row { repeat(2) { GridItemPlaceHolder() } } } } } else { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) .asPaddingValues(), ) { chartsPage?.sections?.filter { it.title != "Top music videos" }?.forEach { section -> item(key = "section_title_${section.title}") { NavigationTitle( title = when (section.title) { "Trending" -> stringResource(R.string.trending) else -> section.title.ifEmpty { stringResource(R.string.charts) } }, modifier = Modifier.animateItem(), ) } item(key = "section_content_${section.title}") { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor val lazyGridState = rememberLazyGridState() val snapLayoutInfoProvider = remember(lazyGridState) { SnapLayoutInfoProvider( lazyGridState = lazyGridState, positionInLayout = { layoutSize, itemSize -> (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) }, ) } LazyHorizontalGrid( state = lazyGridState, rows = GridCells.Fixed(4), flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), modifier = Modifier .fillMaxWidth() .height(ListItemHeight * 4) .animateItem(), ) { items( items = section.items.filterIsInstance().distinctBy { it.id }, key = { it.id }, ) { song -> YouTubeListItem( item = song, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, isSwipeable = false, trailingContent = { IconButton( onClick = { menuState.show { YouTubeSongMenu( song = 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.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue( endpoint = WatchEndpoint(videoId = song.id), preloadItem = song.toMediaMetadata(), ), ) } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { YouTubeSongMenu( song = song, navController = navController, onDismiss = menuState::dismiss, ) } }, ), ) } } } } } chartsPage?.sections?.find { it.title == "Top music videos" }?.let { topVideosSection -> item(key = "top_videos_title") { NavigationTitle( title = stringResource(R.string.top_music_videos), modifier = Modifier.animateItem(), ) } item(key = "top_videos_content") { LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), modifier = Modifier.animateItem(), ) { items( items = topVideosSection.items.filterIsInstance().distinctBy { it.id }, key = { it.id }, ) { video -> YouTubeGridItem( item = video, isActive = video.id == mediaMetadata?.id, isPlaying = isPlaying, coroutineScope = coroutineScope, modifier = Modifier .combinedClickable( onClick = { if (video.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue( endpoint = WatchEndpoint(videoId = video.id), preloadItem = video.toMediaMetadata(), ), ) } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { YouTubeSongMenu( song = video, navController = navController, onDismiss = menuState::dismiss, ) } }, ) .animateItem(), ) } } } } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/CrashActivity.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme 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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding 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.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.runtime.Composable 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.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.FileProvider import com.metrolist.music.R import com.metrolist.music.ui.theme.MetrolistTheme import com.metrolist.music.utils.CrashHandler import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale class CrashActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() val crashLog = intent.getStringExtra(CrashHandler.EXTRA_CRASH_LOG) ?: getString(R.string.crash_no_log) setContent { val darkTheme = isSystemInDarkTheme() MetrolistTheme(darkTheme = darkTheme) { CrashScreen( crashLog = crashLog, onClose = { finishAffinity() }, onShare = { shareCrashLog(crashLog) } ) } } } private fun shareCrashLog(crashLog: String) { try { // Create crash log file val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) val fileName = "metrolist_crash_$timestamp.txt" val crashFile = File(cacheDir, fileName) crashFile.writeText(crashLog) // Get URI using FileProvider val uri = FileProvider.getUriForFile( this, "${packageName}.FileProvider", crashFile ) // Create share intent val shareIntent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_STREAM, uri) putExtra(Intent.EXTRA_SUBJECT, getString(R.string.crash_report_subject)) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } startActivity(Intent.createChooser(shareIntent, getString(R.string.crash_share_title))) } catch (e: Exception) { // Fallback to simple text share if file sharing fails val shareIntent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, crashLog) putExtra(Intent.EXTRA_SUBJECT, getString(R.string.crash_report_subject)) } startActivity(Intent.createChooser(shareIntent, getString(R.string.crash_share_title))) } } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun CrashScreen( crashLog: String, onClose: () -> Unit, onShare: () -> Unit ) { val context = LocalContext.current Scaffold( topBar = { TopAppBar( title = { Text( text = stringResource(R.string.crash_title), style = MaterialTheme.typography.headlineSmall ) }, actions = { IconButton(onClick = onClose) { Icon( painter = painterResource(R.drawable.close), contentDescription = stringResource(R.string.crash_close) ) } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface ) ) }, floatingActionButton = { ExtendedFloatingActionButton( onClick = onShare, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null ) }, text = { Text(stringResource(R.string.crash_share_logs)) }, containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer ) }, containerColor = MaterialTheme.colorScheme.surface ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()) ) { Text( text = stringResource(R.string.crash_description), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(16.dp)) // Crash log container Box( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHighest) .padding(16.dp) ) { Text( text = crashLog, style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace, fontSize = 11.sp, lineHeight = 16.sp ), color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.horizontalScroll(rememberScrollState()) ) } // Bottom spacing for FAB Spacer(modifier = Modifier.height(88.dp)) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/ExploreScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.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.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.NavigationTitle import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.component.shimmer.GridItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.component.shimmer.TextPlaceholder import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.SnapLayoutInfoProvider import com.metrolist.music.viewmodels.ChartsViewModel import com.metrolist.music.viewmodels.ExploreViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ExploreScreen( navController: NavController, exploreViewModel: ExploreViewModel = hiltViewModel(), chartsViewModel: ChartsViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val explorePage by exploreViewModel.explorePage.collectAsState() val chartsPage by chartsViewModel.chartsPage.collectAsState() val isChartsLoading by chartsViewModel.isLoading.collectAsState() val coroutineScope = rememberCoroutineScope() val scrollState = rememberScrollState() val backStackEntry by navController.currentBackStackEntryAsState() val scrollToTop by backStackEntry?.savedStateHandle ?.getStateFlow("scrollToTop", false)?.collectAsState() ?: return LaunchedEffect(Unit) { if (chartsPage == null) { chartsViewModel.loadCharts() } } LaunchedEffect(scrollToTop) { if (scrollToTop) { scrollState.animateScrollTo(0) backStackEntry?.savedStateHandle?.set("scrollToTop", false) } } Box( modifier = Modifier.fillMaxSize(), ) { Column( modifier = Modifier.verticalScroll(scrollState), ) { Spacer( Modifier.height( LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateTopPadding(), ), ) if (isChartsLoading || chartsPage == null || explorePage == null) { ShimmerHost { TextPlaceholder( height = 36.dp, modifier = Modifier .padding(12.dp) .fillMaxWidth(0.5f), ) BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor LazyHorizontalGrid( rows = GridCells.Fixed(4), contentPadding = PaddingValues(start = 4.dp), modifier = Modifier .fillMaxWidth() .height(ListItemHeight * 4), ) { items(4) { Row( modifier = Modifier .width(horizontalLazyGridItemWidth) .padding(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier .size(ListItemHeight - 16.dp) .clip(RoundedCornerShape(4.dp)) .background(MaterialTheme.colorScheme.onSurface), ) Spacer(modifier = Modifier.width(8.dp)) Column( modifier = Modifier.fillMaxHeight(), verticalArrangement = Arrangement.Center, ) { Box( modifier = Modifier .height(16.dp) .width(120.dp) .background(MaterialTheme.colorScheme.onSurface), ) Spacer(modifier = Modifier.height(8.dp)) Box( modifier = Modifier .height(12.dp) .width(80.dp) .background(MaterialTheme.colorScheme.onSurface), ) } } } } } TextPlaceholder( height = 36.dp, modifier = Modifier .padding(vertical = 12.dp, horizontal = 12.dp) .width(250.dp), ) Row { repeat(2) { GridItemPlaceHolder() } } TextPlaceholder( height = 36.dp, modifier = Modifier .padding(vertical = 12.dp, horizontal = 12.dp) .width(250.dp), ) Row { repeat(2) { GridItemPlaceHolder() } } TextPlaceholder( height = 36.dp, modifier = Modifier .padding(vertical = 12.dp, horizontal = 12.dp) .width(250.dp), ) repeat(4) { Row { repeat(2) { TextPlaceholder( height = MoodAndGenresButtonHeight, modifier = Modifier .padding(horizontal = 6.dp) .width(200.dp), ) } } } } } else { chartsPage?.sections?.filter { it.title != "Top music videos" }?.forEach { section -> NavigationTitle( title = when (section.title) { "Trending" -> stringResource(R.string.trending) else -> section.title.ifEmpty { stringResource(R.string.charts) } }, ) BoxWithConstraints( modifier = Modifier.fillMaxWidth() ) { val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor val lazyGridState = rememberLazyGridState() val snapLayoutInfoProvider = remember(lazyGridState) { SnapLayoutInfoProvider( lazyGridState = lazyGridState, positionInLayout = { layoutSize, itemSize -> (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) }, ) } LazyHorizontalGrid( state = lazyGridState, rows = GridCells.Fixed(4), flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), modifier = Modifier .fillMaxWidth() .height(ListItemHeight * 4), ) { items( items = section.items.filterIsInstance().distinctBy { it.id }, key = { it.id }, ) { song -> YouTubeListItem( item = song, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, isSwipeable = false, trailingContent = { IconButton( onClick = { menuState.show { YouTubeSongMenu( song = 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.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue( endpoint = WatchEndpoint(videoId = song.id), preloadItem = song.toMediaMetadata(), ), ) } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { YouTubeSongMenu( song = song, navController = navController, onDismiss = menuState::dismiss, ) } }, ), ) } } } } explorePage?.newReleaseAlbums?.let { newReleaseAlbums -> NavigationTitle( title = stringResource(R.string.new_release_albums), onClick = { navController.navigate("new_release") }, ) LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), ) { items( items = newReleaseAlbums.distinctBy { it.id }, key = { it.id }, ) { album -> YouTubeGridItem( item = album, isActive = mediaMetadata?.album?.id == album.id, isPlaying = isPlaying, 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(), ) } } } chartsPage?.sections?.find { it.title == "Top music videos" }?.let { topVideosSection -> NavigationTitle( title = stringResource(R.string.top_music_videos), ) LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), ) { items( items = topVideosSection.items.filterIsInstance().distinctBy { it.id }, key = { it.id }, ) { video -> YouTubeGridItem( item = video, isActive = video.id == mediaMetadata?.id, isPlaying = isPlaying, coroutineScope = coroutineScope, modifier = Modifier .combinedClickable( onClick = { if (video.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue( endpoint = WatchEndpoint(videoId = video.id), preloadItem = video.toMediaMetadata(), ), ) } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { YouTubeSongMenu( song = video, navController = navController, onDismiss = menuState::dismiss, ) } }, ) .animateItem(), ) } } } explorePage?.moodAndGenres?.let { moodAndGenres -> NavigationTitle( title = stringResource(R.string.mood_and_genres), onClick = { navController.navigate("mood_and_genres") }, ) LazyHorizontalGrid( rows = GridCells.Fixed(4), contentPadding = PaddingValues(6.dp), modifier = Modifier.height((MoodAndGenresButtonHeight + 12.dp) * 4 + 12.dp), ) { items(moodAndGenres) { MoodAndGenresButton( title = it.title, onClick = { navController.navigate("youtube_browse/${it.endpoint.browseId}?params=${it.endpoint.params}") }, modifier = Modifier .padding(6.dp) .width(180.dp), ) } } } } Spacer( Modifier.height( LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding() ) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/HistoryScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.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.Checkbox 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.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.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.util.fastForEachReversed import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.innertube.utils.parseCookieString import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.HistorySource import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.extensions.metadata import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.NavigationTitle import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.menu.SelectionMediaMetadataMenu import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.DateAgo import com.metrolist.music.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.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, Long>( save = { it.toList() }, restore = { it.toMutableStateList() }, ), ) { mutableStateListOf() } val onExitSelectionMode = { inSelectMode = false selection.clear() } 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 (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } val historySource by viewModel.historySource.collectAsState() val historyPage by viewModel.historyPage.collectAsState() val events by viewModel.events.collectAsState() val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) } @Composable fun dateAgoToString(dateAgo: DateAgo): String = when (dateAgo) { DateAgo.Today -> stringResource(R.string.today) DateAgo.Yesterday -> stringResource(R.string.yesterday) DateAgo.ThisWeek -> stringResource(R.string.this_week) DateAgo.LastWeek -> stringResource(R.string.last_week) is DateAgo.Other -> dateAgo.date.format(DateTimeFormatter.ofPattern("yyyy/MM")) } 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 allEvents = remember(filteredEvents) { filteredEvents.values.flatten() } LaunchedEffect(allEvents) { selection.fastForEachReversed { eventId -> if (allEvents.find { it.event.id == eventId } == null) { selection.remove(eventId) } } } val lazyListState = rememberLazyListState() Box(Modifier.fillMaxSize()) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) .asPaddingValues(), modifier = Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Top, ), ), ) { item(key = "chips_row") { 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}_${section.songs.indexOf(it)}" }, ) { song -> YouTubeListItem( item = song, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, trailingContent = { IconButton( onClick = { menuState.show { YouTubeSongMenu( song = song, navController = navController, onDismiss = menuState::dismiss, onHistoryRemoved = { viewModel.fetchRemoteHistory() }, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, ) } }, modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = { if (song.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue.radio(song.toMediaMetadata()), ) } }, onLongClick = { menuState.show { YouTubeSongMenu( song = song, navController = navController, onDismiss = menuState::dismiss, onHistoryRemoved = { viewModel.fetchRemoteHistory() }, ) } }, ).animateItem(), ) } } } else { filteredEvents.forEach { (dateAgo, dateEvents) -> stickyHeader { NavigationTitle( title = dateAgoToString(dateAgo), modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surface), ) } itemsIndexed( items = dateEvents, key = { index, event -> "${dateAgo}_${event.event.id}_$index" }, ) { index, event -> val onCheckedChange: (Boolean) -> Unit = { if (it) { selection.add(event.event.id) } else { selection.remove(event.event.id) } } val dateTitle = dateAgoToString(dateAgo) SongListItem( song = event.song, isActive = event.song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, trailingContent = { if (inSelectMode) { Checkbox( checked = event.event.id in selection, onCheckedChange = onCheckedChange, ) } else { IconButton( onClick = { 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 (inSelectMode) { onCheckedChange(event.event.id !in selection) } else if (event.song.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( ListQueue( title = dateTitle, items = dateEvents.map { it.song.toMediaItem() }, startIndex = index, ), ) } }, onLongClick = { if (!inSelectMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) } }, ).animateItem(), ) } } } } val historyTitle = stringResource(R.string.history) HideOnScrollFAB( visible = if (historySource == HistorySource.REMOTE) { filteredRemoteContent?.any { it.songs.isNotEmpty() } == true } else { allEvents.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 = historyTitle, items = songs.map { it.toMediaItem() }.shuffled(), ), ) } } else { playerConnection.playQueue( ListQueue( title = historyTitle, items = allEvents.map { it.song.toMediaItem() }.shuffled(), ), ) } }, ) } TopAppBar( title = { if (inSelectMode) { Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) } 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 = { if (inSelectMode) { IconButton(onClick = onExitSelectionMode) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, ) } } else { IconButton( onClick = { if (isSearching) { isSearching = false query = TextFieldValue() } else { navController.navigateUp() } }, onLongClick = { if (!isSearching) { navController.backToMain() } }, ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = null, ) } } }, actions = { if (inSelectMode) { Checkbox( checked = selection.size == allEvents.size && selection.isNotEmpty(), onCheckedChange = { if (selection.size == allEvents.size) { selection.clear() } else { selection.clear() selection.addAll(allEvents.map { it.event.id }) } }, ) IconButton( enabled = selection.isNotEmpty(), onClick = { menuState.show { SelectionMediaMetadataMenu( songSelection = selection.mapNotNull { eventId -> allEvents .find { it.event.id == eventId } ?.song ?.toMediaItem() ?.metadata }, onDismiss = menuState::dismiss, clearAction = onExitSelectionMode, 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, ) } } }, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/HomeScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior 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.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.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.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape 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.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.carousel.HorizontalMultiBrowseCarousel import androidx.compose.material3.carousel.rememberCarouselState import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.surfaceColorAtElevation 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.runtime.snapshotFlow 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.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.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import coil3.compose.AsyncImage import coil3.request.CachePolicy import coil3.request.ImageRequest import coil3.request.crossfade import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.innertube.models.YTItem import com.metrolist.innertube.utils.completed import com.metrolist.innertube.utils.parseCookieString import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.constants.ListThumbnailSize import com.metrolist.music.constants.RandomizeHomeOrderKey import com.metrolist.music.constants.SmallGridThumbnailHeight import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Artist import com.metrolist.music.db.entities.LocalItem import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.PlaylistSongMap import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.playback.queues.LocalAlbumRadio import com.metrolist.music.playback.queues.YouTubeAlbumRadio import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.AlbumGridItem import com.metrolist.music.ui.component.ArtistGridItem import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.LocalBottomSheetPageState import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.NavigationTitle import com.metrolist.music.ui.component.RandomizeGridItem import com.metrolist.music.ui.component.SongGridItem import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.SpeedDialGridItem import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.component.shimmer.GridItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.component.shimmer.TextPlaceholder import com.metrolist.music.ui.menu.AlbumMenu import com.metrolist.music.ui.menu.ArtistMenu import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.menu.YouTubeArtistMenu import com.metrolist.music.ui.menu.YouTubePlaylistMenu import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.SnapLayoutInfoProvider import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.CommunityPlaylistItem import com.metrolist.music.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 sealed class HomeSection( val id: String, val baseWeight: Int, ) { data object SpeedDial : HomeSection("speed_dial", 100) data object QuickPicks : HomeSection("quick_picks", 90) data object DailyDiscover : HomeSection("daily_discover", 80) data object KeepListening : HomeSection("keep_listening", 50) data object AccountPlaylists : HomeSection("account_playlists", 40) data object ForgottenFavorites : HomeSection("forgotten_favorites", 30) data object FromTheCommunity : HomeSection("from_the_community", 20) data class SimilarRecommendation( val index: Int, ) : HomeSection("similar_recommendation_$index", 10) data class HomePageSection( val index: Int, ) : HomeSection("home_page_section_$index", 10) data object MoodAndGenres : HomeSection("mood_and_genres", 5) } @Composable fun CommunityPlaylistCard( item: CommunityPlaylistItem, onClick: () -> Unit, onSongClick: (SongItem) -> Unit, modifier: Modifier = Modifier, ) { val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current val listenTogetherManager = LocalListenTogetherManager.current val isListenTogetherGuest = listenTogetherManager?.let { it.isInRoom && !it.isHost } ?: false val scope = rememberCoroutineScope() val isDark = isSystemInDarkTheme() val containerColor = if (isDark) { MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) } else { MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) } val dbPlaylist by database.playlistByBrowseId(item.playlist.id).collectAsState(initial = null) val isBookmarked = dbPlaylist?.playlist?.bookmarkedAt != null Card( modifier = modifier .width(320.dp) .height(420.dp), colors = CardDefaults.cardColors( containerColor = containerColor, ), shape = RoundedCornerShape(28.dp), onClick = onClick, ) { Column( modifier = Modifier.fillMaxSize(), ) { Row( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { // 2x2 Grid of thumbnails Box( modifier = Modifier .size(100.dp) .clip(RoundedCornerShape(12.dp)), ) { Column(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.weight(1f)) { AsyncImage( model = item.songs .getOrNull(0) ?.thumbnail ?.replace(Regex("w\\d+-h\\d+"), "w120-h120"), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .weight(1f) .fillMaxSize(), ) AsyncImage( model = item.songs .getOrNull(1) ?.thumbnail ?.replace(Regex("w\\d+-h\\d+"), "w120-h120"), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .weight(1f) .fillMaxSize(), ) } Row(modifier = Modifier.weight(1f)) { AsyncImage( model = item.songs .getOrNull(2) ?.thumbnail ?.replace(Regex("w\\d+-h\\d+"), "w120-h120"), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .weight(1f) .fillMaxSize(), ) AsyncImage( model = item.songs .getOrNull(3) ?.thumbnail ?.replace(Regex("w\\d+-h\\d+"), "w120-h120"), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .weight(1f) .fillMaxSize(), ) } } } Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center, ) { Text( text = item.playlist.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.height(4.dp)) Text( text = item.playlist.author?.name ?: "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), maxLines = 1, ) } } Column( modifier = Modifier .fillMaxWidth() .weight(1f) .padding(horizontal = 16.dp), ) { item.songs.take(3).forEach { song -> Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) .clip(RoundedCornerShape(12.dp)) .combinedClickable(onClick = { onSongClick(song) }), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { AsyncImage( model = song.thumbnail.replace(Regex("w\\d+-h\\d+"), "w120-h120"), contentDescription = null, modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(12.dp)), contentScale = ContentScale.Crop, ) Column(modifier = Modifier.weight(1f)) { Text( text = song.title, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) Text( text = song.artists.joinToString(", ") { it.name }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) } } } } Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), ) { IconButton( onClick = { if (!isListenTogetherGuest) { item.playlist.playEndpoint?.let { playerConnection?.playQueue(YouTubeQueue(it)) } } }, modifier = Modifier .size(48.dp) .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), ) { Icon( painter = painterResource(R.drawable.ic_widget_play), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(24.dp), ) } IconButton( onClick = { if (!isListenTogetherGuest) { item.playlist.radioEndpoint?.let { playerConnection?.playQueue(YouTubeQueue(it)) } } }, modifier = Modifier .size(48.dp) .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), CircleShape), ) { Icon( painter = painterResource(R.drawable.radio), contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(24.dp), ) } IconButton( onClick = { scope.launch(Dispatchers.IO) { if (dbPlaylist?.playlist == null) { database.transaction { val playlistEntity = PlaylistEntity( name = item.playlist.title, browseId = item.playlist.id, thumbnailUrl = item.playlist.thumbnail, remoteSongCount = item.playlist.songCountText ?.split(" ") ?.firstOrNull() ?.toIntOrNull(), playEndpointParams = item.playlist.playEndpoint?.params, shuffleEndpointParams = item.playlist.shuffleEndpoint?.params, radioEndpointParams = item.playlist.radioEndpoint?.params, ).toggleLike() insert(playlistEntity) scope.launch(Dispatchers.IO) { item.songs .ifEmpty { YouTube .playlist(item.playlist.id) .completed() .getOrNull() ?.songs .orEmpty() }.map { it.toMediaMetadata() } .onEach(::insert) .mapIndexed { index, song -> PlaylistSongMap( songId = song.id, playlistId = playlistEntity.id, position = index, setVideoId = song.setVideoId, ) }.forEach(::insert) } } } else { database.transaction { val currentPlaylist = dbPlaylist!!.playlist update(currentPlaylist.toggleLike()) } } } }, modifier = Modifier .size(48.dp) .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), CircleShape), ) { Icon( painter = painterResource(if (isBookmarked) R.drawable.library_add_check else R.drawable.library_add), contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(24.dp), ) } } } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun DailyDiscoverCard( dailyDiscover: com.metrolist.music.viewmodels.DailyDiscoverItem, onClick: () -> Unit, navController: NavController, modifier: Modifier = Modifier, ) { val database = LocalDatabase.current val playCount by database.getLifetimePlayCount(dailyDiscover.recommendation.id).collectAsState(initial = 0) val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val song = dailyDiscover.recommendation as? SongItem val playsString = stringResource(R.string.plays) Card( modifier = modifier .fillMaxSize() .clip(RoundedCornerShape(28.dp)) .combinedClickable( onClick = onClick, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) if (song != null) { menuState.show { YouTubeSongMenu( song = song, navController = navController, onDismiss = { menuState.dismiss() }, ) } } }, ), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), shape = RoundedCornerShape(28.dp), ) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { AsyncImage( model = ImageRequest .Builder(LocalContext.current) .data(dailyDiscover.recommendation.thumbnail?.replace(Regex("w\\d+-h\\d+"), "w544-h544")) .crossfade(true) .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize(), ) if (maxWidth > 200.dp) { Box( modifier = Modifier .fillMaxSize() .background( brush = Brush.verticalGradient( colors = listOf( Color.Black.copy(alpha = 0.3f), Color.Transparent, Color.Black.copy(alpha = 0.6f), Color.Black.copy(alpha = 0.9f), ), ), ), ) Column( modifier = Modifier .fillMaxSize() .padding(24.dp), verticalArrangement = Arrangement.SpaceBetween, ) { Column { Text( text = dailyDiscover.recommendation.title, style = MaterialTheme.typography.titleMedium, color = Color.White, ) Text( text = buildString { append((dailyDiscover.recommendation as? SongItem)?.artists?.joinToString(", ") { it.name } ?: "") if (playCount > 0) { append(" • $playCount $playsString") } }, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), ) } val messages = listOf( R.string.daily_discover_sounds_like, R.string.daily_discover_because_you_listen_to, R.string.daily_discover_similar_to, R.string.daily_discover_based_on, R.string.daily_discover_for_fans_of, ) val messageRes = remember(dailyDiscover.seed.id) { messages[kotlin.math.abs(dailyDiscover.seed.id.hashCode()) % messages.size] } Text( text = stringResource( messageRes, "${dailyDiscover.seed.title} • ${dailyDiscover.seed.artists.joinToString(", ") { it.name }}", ), style = MaterialTheme.typography.bodySmall, fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, color = Color.White.copy(alpha = 0.6f), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, ) } } } } } @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun HomeScreen( navController: NavController, snackbarHostState: SnackbarHostState, viewModel: HomeViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val bottomSheetPageState = LocalBottomSheetPageState.current val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return val haptic = LocalHapticFeedback.current val listenTogetherManager = LocalListenTogetherManager.current val isListenTogetherGuest = listenTogetherManager?.let { it.isInRoom && !it.isHost } ?: false val isPlaying by playerConnection.isEffectivelyPlaying.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 dailyDiscover by viewModel.dailyDiscover.collectAsState() val communityPlaylists by viewModel.communityPlaylists.collectAsState() val allLocalItems by viewModel.allLocalItems.collectAsState() val allYtItems by viewModel.allYtItems.collectAsState() val speedDialItems by viewModel.speedDialItems.collectAsState() val pinnedSpeedDialItems by viewModel.pinnedSpeedDialItems.collectAsState() val selectedChip by viewModel.selectedChip.collectAsState() // Official podcast API data val savedPodcastShows by viewModel.savedPodcastShows.collectAsState() val episodesForLater by viewModel.episodesForLater.collectAsState() val isLoading: Boolean by viewModel.isLoading.collectAsState() val isMoodAndGenresLoading = isLoading && explorePage?.moodAndGenres == null val isRefreshing by viewModel.isRefreshing.collectAsState() val isRandomizing by viewModel.isRandomizing.collectAsState() val pullRefreshState = rememberPullToRefreshState() val quickPicksLazyGridState = rememberLazyGridState() val forgottenFavoritesLazyGridState = rememberLazyGridState() val accountName by viewModel.accountName.collectAsState() val accountImageUrl by viewModel.accountImageUrl.collectAsState() val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") val (randomizeHomeOrder) = rememberPreference(RandomizeHomeOrderKey, true) val shouldShowWrappedCard by viewModel.showWrappedCard.collectAsState() val wrappedState by viewModel.wrappedManager.state.collectAsState() val isWrappedDataReady = wrappedState.isDataReady val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) } val url = if (isLoggedIn) accountImageUrl else null // Extract unique podcasts from episodes for "Podcast Channels" row // Cache the podcasts to prevent them from disappearing during refresh var cachedPodcasts by remember { mutableStateOf>(emptyList()) } val featuredPodcasts = remember(homePage, selectedChip) { if (selectedChip == null) { cachedPodcasts = emptyList() emptyList() } else { val newPodcasts = homePage ?.sections ?.flatMap { it.items } ?.filterIsInstance() ?.mapNotNull { episode -> episode.podcast?.let { podcast -> PodcastItem( id = podcast.id, title = podcast.name, author = episode.author, episodeCountText = null, thumbnail = episode.thumbnail, playEndpoint = null, shuffleEndpoint = null, ) } }?.distinctBy { it.id } ?.shuffled() ?.take(10) ?: emptyList() // Only update cache if we got valid data; keep old data during refresh if (newPodcasts.isNotEmpty()) { cachedPodcasts = newPodcasts } cachedPodcasts } } val scope = rememberCoroutineScope() // Track randomization job var randomizeJob by remember { mutableStateOf(null) } val lazylistState = rememberLazyListState() val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) val currentGridHeight = if (gridItemSize == GridItemSize.BIG) GridThumbnailHeight else SmallGridThumbnailHeight val backStackEntry by navController.currentBackStackEntryAsState() val scrollToTop = backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() val wrappedDismissed by backStackEntry ?.savedStateHandle ?.getStateFlow("wrapped_seen", false) ?.collectAsState() ?: remember { mutableStateOf(false) } var randomSeed by rememberSaveable { mutableLongStateOf(System.currentTimeMillis()) } LaunchedEffect(isRefreshing) { if (isRefreshing) { randomSeed = System.currentTimeMillis() } } val foundInSettings = stringResource(R.string.found_in_settings_content) LaunchedEffect(wrappedDismissed) { if (wrappedDismissed) { viewModel.markWrappedAsSeen() scope.launch { snackbarHostState.showSnackbar(foundInSettings) } backStackEntry?.savedStateHandle?.set("wrapped_seen", false) // Reset the value } } LaunchedEffect(scrollToTop?.value) { if (scrollToTop?.value == true) { lazylistState.animateScrollToItem(0) backStackEntry?.savedStateHandle?.set("scrollToTop", false) } } LaunchedEffect(Unit) { snapshotFlow { lazylistState.layoutInfo.visibleItemsInfo .lastOrNull() ?.index }.collect { lastVisibleIndex -> val len = lazylistState.layoutInfo.totalItemsCount if (lastVisibleIndex != null && lastVisibleIndex >= len - 3) { viewModel.loadMoreYouTubeItems(homePage?.continuation) } } } if (selectedChip != null) { BackHandler { // if a chip is selected, go back to the normal homepage first viewModel.toggleChip(selectedChip) } } val localGridItem: @Composable (LocalItem) -> Unit = { when (it) { is Song -> { SongGridItem( song = it, modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = { if (!isListenTogetherGuest) { if (it.id == mediaMetadata?.id) { playerConnection.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 -> { if (!isListenTogetherGuest) { 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}") } is PodcastItem -> { navController.navigate("online_podcast/${item.id}") } is EpisodeItem -> { if (!isListenTogetherGuest) { playerConnection.playQueue( ListQueue( title = item.title, items = listOf(item.toMediaMetadata().toMediaItem()), ), ) } } } }, 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, ) } is PodcastItem -> { YouTubePlaylistMenu( playlist = item.asPlaylistItem(), coroutineScope = scope, onDismiss = menuState::dismiss, ) } is EpisodeItem -> { YouTubeSongMenu( song = item.asSongItem(), navController = navController, onDismiss = menuState::dismiss, ) } } } }, ), ) } val homeSections = remember( randomizeHomeOrder, randomSeed, selectedChip, speedDialItems, quickPicks, dailyDiscover, keepListening, accountPlaylists, forgottenFavorites, communityPlaylists, similarRecommendations, homePage?.sections, explorePage?.moodAndGenres, ) { val list = mutableListOf() val chipActive = selectedChip != null if (!chipActive && speedDialItems.isNotEmpty()) list.add(HomeSection.SpeedDial) if (!chipActive && quickPicks?.isNotEmpty() == true) list.add(HomeSection.QuickPicks) if (!chipActive && communityPlaylists?.isNotEmpty() == true) list.add(HomeSection.FromTheCommunity) if (!chipActive && dailyDiscover?.isNotEmpty() == true) list.add(HomeSection.DailyDiscover) if (!chipActive && keepListening?.isNotEmpty() == true) list.add(HomeSection.KeepListening) if (!chipActive && accountPlaylists?.isNotEmpty() == true) list.add(HomeSection.AccountPlaylists) if (!chipActive && forgottenFavorites?.isNotEmpty() == true) list.add(HomeSection.ForgottenFavorites) if (!chipActive) { similarRecommendations?.indices?.forEach { i -> list.add(HomeSection.SimilarRecommendation(i)) } } homePage?.sections?.indices?.forEach { i -> list.add(HomeSection.HomePageSection(i)) } if (explorePage?.moodAndGenres != null) list.add(HomeSection.MoodAndGenres) if (randomizeHomeOrder) { list.sortedByDescending { section -> // Use a stable seed for each section based on the session seed + section ID hash // This ensures the weight for a specific section remains constant during a session (until refresh) // even if other sections appear/disappear, preventing jumping. val sectionRandom = Random(randomSeed + section.id.hashCode()) // Flatten the base values to allow for more overlap and variation // All "main" sections start closer together val base = when (section) { HomeSection.SpeedDial, HomeSection.QuickPicks, HomeSection.DailyDiscover, -> 500 // Top tier starts equal HomeSection.KeepListening, HomeSection.AccountPlaylists, HomeSection.ForgottenFavorites, HomeSection.FromTheCommunity, -> 300 // Middle tier starts equal else -> 100 // Bottom tier } val modifier = when (section) { // Top tier: High variance to allow shuffling among themselves // Range: [500-200, 500+400] = [300, 900] HomeSection.SpeedDial, HomeSection.QuickPicks, HomeSection.DailyDiscover, -> sectionRandom.nextInt(-200, 400) // Middle tier: Can jump up to challenge top tier, or drop lower // Range: [300-100, 300+400] = [200, 700] // This allows them to occasionally appear above a "bad roll" top tier item HomeSection.KeepListening, HomeSection.AccountPlaylists, HomeSection.ForgottenFavorites, HomeSection.FromTheCommunity, -> sectionRandom.nextInt(-100, 400) // Bottom tier: Standard variance else -> sectionRandom.nextInt(-50, 50) } base + modifier } } else { val defaultOrder = mapOf( HomeSection.SpeedDial to 100, HomeSection.QuickPicks to 90, HomeSection.FromTheCommunity to 80, HomeSection.DailyDiscover to 70, HomeSection.KeepListening to 60, HomeSection.AccountPlaylists to 50, HomeSection.ForgottenFavorites to 40, HomeSection.MoodAndGenres to 10, ) list.sortedByDescending { section -> when (section) { is HomeSection.SimilarRecommendation -> 30 - section.index is HomeSection.HomePageSection -> 20 - section.index else -> defaultOrder[section] ?: 0 } } } } LaunchedEffect(quickPicks) { quickPicksLazyGridState.scrollToItem(0) } LaunchedEffect(forgottenFavorites) { forgottenFavoritesLazyGridState.scrollToItem(0) } PullToRefreshBox( state = pullRefreshState, isRefreshing = isRefreshing, onRefresh = viewModel::refresh, indicator = { Indicator( isRefreshing = isRefreshing, state = pullRefreshState, modifier = Modifier .align(Alignment.TopCenter) .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()), ) }, ) { BoxWithConstraints( modifier = Modifier.fillMaxSize(), 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) }, ) } LazyColumn( state = lazylistState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { item { ChipsRow( chips = homePage?.chips?.map { it to it.title } ?: emptyList(), currentValue = selectedChip, onValueUpdate = { viewModel.toggleChip(it) }, ) } if (isLoading && homePage?.chips.isNullOrEmpty()) { item(key = "chips_shimmer") { ShimmerHost(showGradient = false) { LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) { items(5) { TextPlaceholder( height = 30.dp, shape = RoundedCornerShape(16.dp), modifier = Modifier.width(72.dp), ) } } } } } // Show podcast sections FIRST when podcast chip is selected (fixed at top) if (selectedChip?.title?.contains("Podcast", ignoreCase = true) == true) { // Show "Your Shows" section from official API if (savedPodcastShows.isNotEmpty()) { item(key = "00_your_shows_title") { NavigationTitle( title = stringResource(R.string.your_shows), onClick = { navController.navigate("youtube_browse/FEmusic_library_non_music_audio_list") }, ) } item(key = "00_your_shows_list") { LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), ) { items(savedPodcastShows) { podcast -> ytGridItem(podcast) } } } } // Show "Episodes for Later" section from official API if (episodesForLater.isNotEmpty()) { item(key = "00_episodes_for_later_title") { NavigationTitle( title = stringResource(R.string.episodes_for_later), onClick = { navController.navigate("online_playlist/SE") }, ) } item(key = "00_episodes_for_later_list") { LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), ) { items(episodesForLater) { episode -> ytGridItem(episode) } } } } // Show Podcast Channels row if we have any (extracted from episodes) // Only show if "Your Shows" from official API is empty (to avoid duplicates) if (featuredPodcasts.isNotEmpty() && savedPodcastShows.isEmpty()) { item(key = "0_podcast_channels_title") { NavigationTitle( title = stringResource(R.string.podcast_channels), ) } item(key = "0_podcast_channels_list") { LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), ) { items(featuredPodcasts) { podcast -> ytGridItem(podcast) } } } } // Add "Latest Episodes" header before episode sections (if we have any sections) if (homeSections.filterIsInstance().isNotEmpty()) { item(key = "0_latest_episodes_title") { NavigationTitle( title = stringResource(R.string.latest_episodes), ) } } // Render the regular sections from the chip (episodes grouped by category) // Use key prefix "1_" to ensure episodes sort after channels "0_" // Skip sections that duplicate official API sections (Your Shows, Episodes for Later) homeSections.filterIsInstance().forEach { section -> val sectionData = homePage?.sections?.getOrNull(section.index) // Skip if this section duplicates an official API section val skipTitles = listOf("your shows", "episodes for later", "podcast channels", "new episodes") if (sectionData?.title?.lowercase()?.let { title -> skipTitles.any { title.contains(it) } } == true) { return@forEach } sectionData?.let { item(key = "1_chip_section_title_${section.index}") { NavigationTitle( title = sectionData.title, label = sectionData.label, thumbnail = sectionData.thumbnail?.let { thumbnailUrl -> { val shape = if (sectionData.endpoint?.isArtistEndpoint == true) { CircleShape } else { RoundedCornerShape( ThumbnailCornerRadius, ) } AsyncImage( model = thumbnailUrl, contentDescription = null, modifier = Modifier .size(ListThumbnailSize) .clip(shape), ) } }, onClick = sectionData.endpoint?.let { endpoint -> { when { endpoint.browseId == "FEmusic_moods_and_genres" -> { navController.navigate("mood_and_genres") } endpoint.params != null -> { navController.navigate( "youtube_browse/${endpoint.browseId}?params=${endpoint.params}", ) } else -> { navController.navigate("browse/${endpoint.browseId}") } } } }, modifier = Modifier.animateItem(), ) } item(key = "1_chip_section_list_${section.index}") { LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), ) { items(sectionData.items) { item -> ytGridItem(item) } } } } } } if (selectedChip == null) { item(key = "wrapped_card") { AnimatedVisibility(visible = shouldShowWrappedCard) { Card( modifier = Modifier .fillMaxWidth() .padding(16.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), ) { Box( modifier = Modifier .fillMaxWidth(), contentAlignment = Alignment.Center, ) { if (isWrappedDataReady) { val bbhFont = try { FontFamily(Font(R.font.bbh_bartle_regular)) } catch (e: Exception) { FontFamily.Default } Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, ) { Text( text = stringResource(R.string.wrapped_ready_title), style = MaterialTheme.typography.headlineLarge.copy( fontFamily = bbhFont, textAlign = TextAlign.Center, ), ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.wrapped_ready_subtitle), style = MaterialTheme.typography.bodyLarge.copy( textAlign = TextAlign.Center, ), ) Spacer(modifier = Modifier.height(16.dp)) Button(onClick = { navController.navigate("wrapped") }) { Text(stringResource(R.string.open)) } } } else { ContainedLoadingIndicator() } } } } } } homeSections.forEach { section -> when (section) { HomeSection.SpeedDial -> { speedDialItems.takeIf { it.isNotEmpty() }?.let { items -> item(key = "speed_dial_title") { NavigationTitle( title = stringResource(R.string.speed_dial), modifier = Modifier.animateItem(), ) } item(key = "speed_dial_list") { val pagerState = rememberPagerState(pageCount = { (items.size + 8) / 9 }) val availableWidth = maxWidth - 32.dp val itemWidth = availableWidth / 3 Column( modifier = Modifier .fillMaxWidth() .animateItem(), ) { HorizontalPager( state = pagerState, contentPadding = PaddingValues(horizontal = 16.dp), pageSpacing = 16.dp, modifier = Modifier .fillMaxWidth() .height(itemWidth * 3), ) { page -> val pageStartIndex = page * 9 val pageItems = items.drop(pageStartIndex).take(9) Column(modifier = Modifier.fillMaxSize()) { for (row in 0 until 3) { Row(modifier = Modifier.fillMaxWidth()) { for (col in 0 until 3) { val itemIndex = row * 3 + col val isRandomizeSlot = (page == 0 && itemIndex == 8) if (isRandomizeSlot) { Box( modifier = Modifier .width(itemWidth) .height(itemWidth) .padding(4.dp), ) { RandomizeGridItem( isLoading = isRandomizing, onClick = { if (isRandomizing) { randomizeJob?.cancel() } else if (!isListenTogetherGuest) { randomizeJob = scope.launch { val randomItem = viewModel.getRandomItem() if (randomItem != null) { when (randomItem) { is SongItem -> { playerConnection.playQueue( YouTubeQueue( randomItem.endpoint ?: WatchEndpoint( videoId = randomItem.id, ), randomItem.toMediaMetadata(), ), ) } is AlbumItem -> { navController.navigate( "album/${randomItem.id}", ) } is ArtistItem -> { navController.navigate( "artist/${randomItem.id}", ) } is PlaylistItem -> { navController.navigate( "online_playlist/${randomItem.id}", ) } is PodcastItem -> { navController.navigate( "online_podcast/${randomItem.id}", ) } is EpisodeItem -> { playerConnection.playQueue( ListQueue( title = randomItem.title, items = listOf( randomItem .toMediaMetadata() .toMediaItem(), ), ), ) } } } } } }, ) } } else if (itemIndex < pageItems.size) { val item = pageItems[itemIndex] val isPinned by database.speedDialDao .isPinned( item.id, ).collectAsState(initial = false) Box( modifier = Modifier .width(itemWidth) .height(itemWidth) .padding(4.dp), ) { SpeedDialGridItem( item = item, isPinned = isPinned, isActive = item.id in listOf(mediaMetadata?.album?.id, mediaMetadata?.id), isPlaying = isPlaying, modifier = Modifier .fillMaxSize() .combinedClickable( onClick = { when (item) { is SongItem -> { if (!isListenTogetherGuest) { 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 -> { val rawType = pinnedSpeedDialItems .find { it.id == item.id }?.type if (rawType == "LOCAL_PLAYLIST") { navController.navigate( "local_playlist/${item.id}", ) } else { navController.navigate( "online_playlist/${item.id}", ) } } is PodcastItem -> { navController.navigate( "online_podcast/${item.id}", ) } is EpisodeItem -> { if (!isListenTogetherGuest) { playerConnection.playQueue( ListQueue( title = item.title, items = listOf( item .toMediaMetadata() .toMediaItem(), ), ), ) } } } }, 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, ) } is PodcastItem -> { YouTubePlaylistMenu( playlist = item.asPlaylistItem(), coroutineScope = scope, onDismiss = menuState::dismiss, ) } is EpisodeItem -> { YouTubeSongMenu( song = item.asSongItem(), navController = navController, onDismiss = menuState::dismiss, ) } } } }, ), ) } } else { Spacer(modifier = Modifier.width(itemWidth)) } } } } } } if (pagerState.pageCount > 1) { Row( modifier = Modifier .height(24.dp) .fillMaxWidth(), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { repeat(pagerState.pageCount) { iteration -> val color = if (pagerState.currentPage == iteration) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) } Box( modifier = Modifier .padding(4.dp) .clip(CircleShape) .background(color) .size(8.dp), ) } } } } } } } HomeSection.QuickPicks -> { quickPicks?.takeIf { it.isNotEmpty() }?.let { quickPicks -> item(key = "quick_picks_title") { val quickPicksTitle = stringResource(R.string.quick_picks) NavigationTitle( title = quickPicksTitle, modifier = Modifier.animateItem(), onPlayAllClick = if (!isListenTogetherGuest) { { playerConnection.playQueue( ListQueue( title = quickPicksTitle, items = quickPicks.distinctBy { it.id }.map { it.toMediaItem() }, ), ) } } else { null }, ) } item(key = "quick_picks_list") { 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.distinctBy { it.id }, 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, isSwipeable = false, 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 (!isListenTogetherGuest) { if (song!!.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue.radio( song!!.toMediaMetadata(), ), ) } } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { SongMenu( originalSong = song!!, navController = navController, onDismiss = menuState::dismiss, ) } }, ), ) } } } } } HomeSection.FromTheCommunity -> { communityPlaylists?.takeIf { it.isNotEmpty() }?.let { playlists -> item(key = "community_playlists_title") { NavigationTitle( title = stringResource(R.string.from_the_community), modifier = Modifier.animateItem(), ) } item(key = "community_playlists_content") { LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.animateItem(), ) { items(playlists) { item -> CommunityPlaylistCard( item = item, onClick = { navController.navigate("online_playlist/${item.playlist.id.removePrefix("VL")}") }, onSongClick = { song -> if (!isListenTogetherGuest) { playerConnection.playQueue( YouTubeQueue( song.endpoint ?: WatchEndpoint(videoId = song.id), song.toMediaMetadata(), ), ) } }, ) } } } } } HomeSection.DailyDiscover -> { dailyDiscover?.takeIf { it.isNotEmpty() }?.let { discoverList -> item(key = "daily_discover_content") { Box( modifier = Modifier .fillMaxWidth() .height(340.dp) .padding(horizontal = 16.dp), contentAlignment = Alignment.Center, ) { val carouselState = rememberCarouselState { discoverList.size } HorizontalMultiBrowseCarousel( state = carouselState, preferredItemWidth = 320.dp, itemSpacing = 16.dp, modifier = Modifier .fillMaxWidth() .height(320.dp), ) { i -> val item = discoverList[i] DailyDiscoverCard( dailyDiscover = item, onClick = { if (!isListenTogetherGuest) { val song = item.recommendation as? SongItem val mediaMetadata = song?.toMediaMetadata() if (mediaMetadata != null) { playerConnection.playQueue( YouTubeQueue( song.endpoint ?: WatchEndpoint(videoId = song.id), mediaMetadata, ), ) } } }, navController = navController, modifier = Modifier.maskClip(MaterialTheme.shapes.extraLarge), ) } } } } } HomeSection.KeepListening -> { keepListening?.takeIf { it.isNotEmpty() }?.let { keepListening -> item(key = "keep_listening_title") { NavigationTitle( title = stringResource(R.string.keep_listening), modifier = Modifier.animateItem(), ) } item(key = "keep_listening_list") { val rows = if (keepListening.size > 6) 2 else 1 LazyHorizontalGrid( state = rememberLazyGridState(), rows = GridCells.Fixed(rows), contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), modifier = Modifier .fillMaxWidth() .height( ( currentGridHeight + with(LocalDensity.current) { MaterialTheme.typography.bodyLarge.lineHeight .toDp() * 2 + MaterialTheme.typography.bodyMedium.lineHeight .toDp() * 2 } ) * rows, ).animateItem(), ) { items(keepListening) { localGridItem(it) } } } } } HomeSection.AccountPlaylists -> { accountPlaylists?.takeIf { it.isNotEmpty() }?.let { accountPlaylists -> item(key = "account_playlists_title") { NavigationTitle( label = stringResource(R.string.your_youtube_playlists), title = accountName, thumbnail = { if (url != null) { AsyncImage( model = ImageRequest .Builder(LocalContext.current) .data(url) .diskCachePolicy(CachePolicy.ENABLED) .diskCacheKey(url) .crossfade(false) .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(key = "account_playlists_list") { LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), modifier = Modifier.animateItem(), ) { items( items = accountPlaylists.distinctBy { it.id }, key = { it.id }, ) { item -> ytGridItem(item) } } } } } HomeSection.ForgottenFavorites -> { forgottenFavorites?.takeIf { it.isNotEmpty() }?.let { forgottenFavorites -> item(key = "forgotten_favorites_title") { val forgottenFavoritesTitle = stringResource(R.string.forgotten_favorites) NavigationTitle( title = forgottenFavoritesTitle, modifier = Modifier.animateItem(), onPlayAllClick = if (!isListenTogetherGuest) { { playerConnection.playQueue( ListQueue( title = forgottenFavoritesTitle, items = forgottenFavorites.distinctBy { it.id }.map { it.toMediaItem() }, ), ) } } else { null }, ) } item(key = "forgotten_favorites_list") { // take min in case list size is less than 4 val rows = min(4, forgottenFavorites.size) LazyHorizontalGrid( state = forgottenFavoritesLazyGridState, rows = GridCells.Fixed(rows), contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), flingBehavior = rememberSnapFlingBehavior( forgottenFavoritesSnapLayoutInfoProvider, ), modifier = Modifier .fillMaxWidth() .height(ListItemHeight * rows) .animateItem(), ) { items( items = forgottenFavorites.distinctBy { it.id }, 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, isSwipeable = false, trailingContent = { IconButton( onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) 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 (!isListenTogetherGuest) { if (song!!.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue.radio( song!!.toMediaMetadata(), ), ) } } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { SongMenu( originalSong = song!!, navController = navController, onDismiss = menuState::dismiss, ) } }, ), ) } } } } } is HomeSection.SimilarRecommendation -> { val recommendation = similarRecommendations?.getOrNull(section.index) recommendation?.let { item(key = "similar_to_title_${section.index}") { NavigationTitle( label = stringResource(R.string.similar_to), title = recommendation.title.title, thumbnail = recommendation.title.thumbnailUrl?.let { thumbnailUrl -> { val shape = if (recommendation.title is Artist) { CircleShape } else { RoundedCornerShape( ThumbnailCornerRadius, ) } AsyncImage( model = thumbnailUrl, contentDescription = null, modifier = Modifier .size(ListThumbnailSize) .clip(shape), ) } }, onClick = { when (recommendation.title) { is Song -> { navController.navigate("album/${recommendation.title.album!!.id}") } is Album -> { navController.navigate("album/${recommendation.title.id}") } is Artist -> { navController.navigate("artist/${recommendation.title.id}") } is Playlist -> {} } }, modifier = Modifier.animateItem(), ) } item(key = "similar_to_list_${section.index}") { LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), modifier = Modifier.animateItem(), ) { items(recommendation.items) { item -> ytGridItem(item) } } } } } is HomeSection.HomePageSection -> { // Skip HomePageSection rendering when podcast chip is selected // Podcast sections are handled separately with special UI if (selectedChip?.title?.contains("Podcast", ignoreCase = true) == true) { return@forEach } val sectionData = homePage?.sections?.getOrNull(section.index) sectionData?.let { // Check if section contains songs for Play All functionality val sectionSongs = sectionData.items.filterIsInstance() val hasPlayableSongs = sectionSongs.isNotEmpty() // Check if this section contains ONLY songs (like Quick picks, Trending songs) val isSongsOnlySection = sectionData.items.isNotEmpty() && sectionData.items.all { it is SongItem } item(key = "home_section_title_${section.index}") { NavigationTitle( title = sectionData.title, label = sectionData.label, thumbnail = sectionData.thumbnail?.let { thumbnailUrl -> { val shape = if (sectionData.endpoint?.isArtistEndpoint == true) { CircleShape } else { RoundedCornerShape( ThumbnailCornerRadius, ) } AsyncImage( model = thumbnailUrl, contentDescription = null, modifier = Modifier .size(ListThumbnailSize) .clip(shape), ) } }, onClick = sectionData.endpoint?.let { endpoint -> { when { endpoint.browseId == "FEmusic_moods_and_genres" -> { navController.navigate("mood_and_genres") } // Handle podcast-related browse endpoints endpoint.browseId.startsWith("FEmusic_library_non_music_audio") || endpoint.browseId.startsWith("FEmusic_non_music_audio") -> { navController.navigate("youtube_browse/${endpoint.browseId}") } endpoint.params != null -> { navController.navigate( "youtube_browse/${endpoint.browseId}?params=${endpoint.params}", ) } else -> { navController.navigate("browse/${endpoint.browseId}") } } } }, onPlayAllClick = if (hasPlayableSongs && !isListenTogetherGuest) { { playerConnection.playQueue( ListQueue( title = sectionData.title, items = sectionSongs.map { it.toMediaMetadata().toMediaItem() }, ), ) } } else { null }, modifier = Modifier.animateItem(), ) } if (isSongsOnlySection) { // Render songs as a horizontal scrollable list (like Quick picks in YouTube Music) item(key = "home_section_list_${section.index}") { LazyHorizontalGrid( state = rememberLazyGridState(), rows = GridCells.Fixed(4), contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), modifier = Modifier .fillMaxWidth() .height(ListItemHeight * 4) .animateItem(), ) { items( items = sectionSongs.distinctBy { it.id }, key = { it.id }, ) { song -> YouTubeListItem( item = song, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, isSwipeable = false, trailingContent = { IconButton( onClick = { menuState.show { YouTubeSongMenu( song = song, navController = navController, onDismiss = menuState::dismiss, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, ) } }, modifier = Modifier .width(horizontalLazyGridItemWidth) .combinedClickable( onClick = { when (song) { is SongItem -> { if (!isListenTogetherGuest) { playerConnection.playQueue( YouTubeQueue( song.endpoint ?: WatchEndpoint(videoId = song.id), song.toMediaMetadata(), ), ) } } // TODO: this will trigger an error in future kotlin releases, make sure it doesnt //is AlbumItem -> { // navController.navigate("album/${song.id}") //} //is ArtistItem -> { // navController.navigate("artist/${song.id}") //} //is PlaylistItem -> { // navController.navigate( // "online_playlist/${song.id.removePrefix("VL")}", // ) //} //is PodcastItem -> { // navController.navigate("online_podcast/${song.id}") //} //is EpisodeItem -> { // if (!isListenTogetherGuest) { // playerConnection.playQueue( // ListQueue( // title = song.title, // items = // listOf( // (song as EpisodeItem) // .toMediaMetadata() // .toMediaItem(), // ), // ), // ) // } //} } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { YouTubeSongMenu( song = song, navController = navController, onDismiss = menuState::dismiss, ) } }, ), ) } } } } else { // Render mixed content as horizontal grid items (albums, playlists, artists, etc.) item(key = "home_section_list_${section.index}") { LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), modifier = Modifier.animateItem(), ) { items(sectionData.items) { item -> ytGridItem(item) } } } } } } HomeSection.MoodAndGenres -> { // Skip MoodAndGenres when podcast chip is selected if (selectedChip?.title?.contains("Podcast", ignoreCase = true) == true) { return@forEach } explorePage?.moodAndGenres?.let { moodAndGenres -> item(key = "mood_and_genres_title") { NavigationTitle( title = stringResource(R.string.mood_and_genres), onClick = { navController.navigate("mood_and_genres") }, modifier = Modifier.animateItem(), ) } item(key = "mood_and_genres_list") { LazyHorizontalGrid( rows = GridCells.Fixed(4), contentPadding = PaddingValues(6.dp), modifier = Modifier .height((MoodAndGenresButtonHeight + 12.dp) * 4 + 12.dp) .animateItem(), ) { items(moodAndGenres) { MoodAndGenresButton( title = it.title, onClick = { navController.navigate( "youtube_browse/${it.endpoint.browseId}?params=${it.endpoint.params}", ) }, modifier = Modifier .padding(6.dp) .width(180.dp), ) } } } } } } } // Only show shimmer during initial loading, not for pagination if (isLoading && homePage?.sections.isNullOrEmpty()) { item(key = "loading_shimmer") { ShimmerHost( modifier = Modifier.animateItem(), ) { repeat(2) { TextPlaceholder( height = 36.dp, modifier = Modifier .padding(12.dp) .width(250.dp), ) LazyRow( contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues(), ) { items(4) { GridItemPlaceHolder() } } } TextPlaceholder( height = 36.dp, modifier = Modifier .padding(vertical = 12.dp, horizontal = 12.dp) .width(250.dp), ) repeat(4) { Row { repeat(2) { TextPlaceholder( height = MoodAndGenresButtonHeight, shape = RoundedCornerShape(6.dp), modifier = Modifier .padding(horizontal = 12.dp) .width(200.dp), ) } } } } } } } HideOnScrollFAB( visible = allLocalItems.isNotEmpty() || allYtItems.isNotEmpty(), lazyListState = lazylistState, icon = R.drawable.shuffle, onClick = { if (!isListenTogetherGuest) { 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)) } } is PodcastItem -> { luckyItem.playEndpoint?.let { playerConnection.playQueue(YouTubeQueue(it)) } } is EpisodeItem -> { playerConnection.playQueue( ListQueue( title = luckyItem.title, items = listOf(luckyItem.toMediaMetadata().toMediaItem()), ), ) } } } } } }, onRecognitionClick = { navController.navigate("recognition") }, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/ListenTogetherScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import android.content.Context import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring 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.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.rememberScrollState 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.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.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.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.focus.onFocusChanged 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.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.AppBarHeight import com.metrolist.music.constants.ListenTogetherInTopBarKey import com.metrolist.music.constants.ListenTogetherUsernameKey import com.metrolist.music.listentogether.ConnectionState import com.metrolist.music.listentogether.JoinRequestPayload import com.metrolist.music.listentogether.ListenTogetherEvent import com.metrolist.music.listentogether.RoomRole import com.metrolist.music.listentogether.SuggestionReceivedPayload import com.metrolist.music.listentogether.UserInfo import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.launch import androidx.compose.material3.IconButton as MaterialIconButton @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ListenTogetherScreen( navController: NavController, showTopBar: Boolean = false, ) { val context = LocalContext.current val listenTogetherManager = LocalListenTogetherManager.current val windowInsets = LocalPlayerAwareWindowInsets.current val joiningRoomTemplate = stringResource(R.string.joining_room) if (listenTogetherManager == null) { NotConfiguredContent() return } val connectionState by listenTogetherManager.connectionState.collectAsState() val roomState by listenTogetherManager.roomState.collectAsState() val userId by listenTogetherManager.userId.collectAsState() val pendingJoinRequests by listenTogetherManager.pendingJoinRequests.collectAsState() val pendingSuggestions by listenTogetherManager.pendingSuggestions.collectAsState() val (listenTogetherInTopBar) = rememberPreference(ListenTogetherInTopBarKey, defaultValue = true) val shouldShowTopBar = showTopBar || listenTogetherInTopBar var savedUsername by rememberPreference(ListenTogetherUsernameKey, "") var roomCodeInput by rememberSaveable { mutableStateOf("") } var usernameInput by rememberSaveable { mutableStateOf(savedUsername) } var isCreatingRoom by rememberSaveable { mutableStateOf(false) } var isJoiningRoom by rememberSaveable { mutableStateOf(false) } var joinErrorMessage by rememberSaveable { mutableStateOf(null) } var selectedUserForMenu by rememberSaveable { mutableStateOf(null) } var selectedUsername by rememberSaveable { mutableStateOf(null) } val waitingForApprovalText = stringResource(R.string.waiting_for_approval) val invalidRoomCodeText = stringResource(R.string.invalid_room_code) val joinRequestDeniedText = stringResource(R.string.join_request_denied) LaunchedEffect(savedUsername) { if (usernameInput.isBlank() && savedUsername.isNotBlank()) { usernameInput = savedUsername } } LaunchedEffect(listenTogetherManager) { listenTogetherManager.events.collect { event -> when (event) { is ListenTogetherEvent.JoinRejected -> { val reason = event.reason joinErrorMessage = when { reason.isNullOrBlank() -> joinRequestDeniedText reason.contains("invalid", ignoreCase = true) -> invalidRoomCodeText else -> "$joinRequestDeniedText: $reason" } isJoiningRoom = false isCreatingRoom = false } is ListenTogetherEvent.JoinApproved -> { isJoiningRoom = false joinErrorMessage = null } is ListenTogetherEvent.RoomCreated -> { isCreatingRoom = false val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText("ListenTogetherRoom", event.roomCode) clipboard.setPrimaryClip(clip) } else -> {} } } } val isInRoom = listenTogetherManager.isInRoom val isHost = roomState?.hostId == userId // User action menu dialog if (selectedUserForMenu != null && selectedUsername != null) { UserActionDialog( username = selectedUsername ?: "", onKick = { selectedUserForMenu?.let { listenTogetherManager.kickUser(it, "Removed by host") } selectedUserForMenu = null selectedUsername = null }, onPermanentKick = { selectedUserForMenu?.let { userId -> selectedUsername?.let { username -> listenTogetherManager.blockUser(username) listenTogetherManager.kickUser(userId, R.string.user_blocked_by_host.toString()) } } selectedUserForMenu = null selectedUsername = null }, onTransferOwnership = { selectedUserForMenu?.let { listenTogetherManager.transferHost(it) } selectedUserForMenu = null selectedUsername = null }, onDismiss = { selectedUserForMenu = null selectedUsername = null }, ) } val lazyListState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() val bringIntoViewRequester = remember { BringIntoViewRequester() } 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) } } LazyColumn( state = lazyListState, modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .imePadding(), contentPadding = PaddingValues( start = 16.dp, end = 16.dp, top = windowInsets.asPaddingValues().calculateTopPadding() + 16.dp, bottom = windowInsets.asPaddingValues().calculateBottomPadding() + 16.dp + AppBarHeight, ), verticalArrangement = Arrangement.spacedBy(16.dp), ) { // Header item { HeaderSection(isInRoom = isInRoom) } // Connection status card item { ConnectionStatusCard( connectionState = connectionState, onConnect = { listenTogetherManager.connect() }, onDisconnect = { listenTogetherManager.disconnect() }, onReconnect = { listenTogetherManager.forceReconnect() }, ) } if (connectionState == ConnectionState.CONNECTED && !isInRoom) { item { Text( text = stringResource(R.string.listen_together_background_disconnect_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) } } if (isInRoom) { // Room status card roomState?.let { room -> item { RoomStatusCard( roomCode = room.roomCode, isHost = isHost, context = context, ) } // Connected users val connectedUsers = room.users.filter { it.isConnected } val currentUserIdValue = userId ?: "" item { ConnectedUsersSection( users = connectedUsers, isHost = isHost, currentUserId = currentUserIdValue, onUserClick = { clickedUserId, username -> if (isHost && clickedUserId != currentUserIdValue) { selectedUserForMenu = clickedUserId selectedUsername = username } }, ) } // Pending join requests (host only) if (isHost && pendingJoinRequests.isNotEmpty()) { item { PendingJoinRequestsSection( requests = pendingJoinRequests, onApprove = { listenTogetherManager.approveJoin(it) }, onReject = { listenTogetherManager.rejectJoin(it, "Rejected by host") }, ) } } // Pending suggestions (host only) if (isHost && pendingSuggestions.isNotEmpty()) { item { PendingSuggestionsSection( suggestions = pendingSuggestions, onApprove = { listenTogetherManager.approveSuggestion(it) }, onReject = { listenTogetherManager.rejectSuggestion(it, "Rejected by host") }, ) } } // Leave room button item { Button( onClick = { listenTogetherManager.leaveRoom() }, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, ), shape = RoundedCornerShape(16.dp), ) { Icon( painter = painterResource(R.drawable.logout), contentDescription = null, modifier = Modifier.size(20.dp), ) Spacer(Modifier.width(8.dp)) Text( stringResource(R.string.leave_room), fontWeight = FontWeight.SemiBold, ) } } } } else { // Join/Create room section item { JoinCreateRoomSection( usernameInput = usernameInput, onUsernameChange = { usernameInput = it }, roomCodeInput = roomCodeInput, onRoomCodeChange = { roomCodeInput = it }, savedUsername = savedUsername, isJoiningRoom = isJoiningRoom, joinErrorMessage = joinErrorMessage, waitingForApprovalText = waitingForApprovalText, bringIntoViewRequester = bringIntoViewRequester, onCreateRoom = { val username = usernameInput.takeIf { it.isNotBlank() } ?: savedUsername val finalUsername = username.trim() if (finalUsername.isNotBlank()) { savedUsername = finalUsername Toast.makeText(context, R.string.creating_room, Toast.LENGTH_SHORT).show() isCreatingRoom = true isJoiningRoom = false joinErrorMessage = null listenTogetherManager.connect() listenTogetherManager.createRoom(finalUsername) } else { Toast.makeText(context, R.string.error_username_empty, Toast.LENGTH_SHORT).show() } }, onJoinRoom = { val username = usernameInput.takeIf { it.isNotBlank() } ?: savedUsername val finalUsername = username.trim() if (finalUsername.isNotBlank()) { savedUsername = finalUsername Toast .makeText( context, String.format(joiningRoomTemplate, roomCodeInput), Toast.LENGTH_SHORT, ).show() isJoiningRoom = true isCreatingRoom = false joinErrorMessage = null listenTogetherManager.connect() listenTogetherManager.joinRoom(roomCodeInput, finalUsername) } else { Toast.makeText(context, R.string.error_username_empty, Toast.LENGTH_SHORT).show() } }, onFieldFocused = { coroutineScope.launch { bringIntoViewRequester.bringIntoView() } }, ) } } // Settings link item { SettingsLinkCard( onClick = { navController.navigate("settings/integrations/listen_together") }, ) } } if (shouldShowTopBar) { TopAppBar( title = { Text(stringResource(R.string.together)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) } } @Composable private fun NotConfiguredContent() { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp), ) { Icon( painter = painterResource(R.drawable.group), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(64.dp), ) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.listen_together), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.listen_together_not_configured), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } @Composable private fun HeaderSection(isInRoom: Boolean = false) { if (isInRoom) return Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Box( modifier = Modifier .size(80.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.group_outlined), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(48.dp), ) } Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.listen_together), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, ) Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.listen_together_description), style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Composable private fun ConnectionStatusCard( connectionState: ConnectionState, onConnect: () -> Unit, onDisconnect: () -> Unit, onReconnect: () -> Unit, ) { Card( modifier = Modifier .fillMaxWidth() .animateContentSize( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, ), ), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors( containerColor = when (connectionState) { ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primaryContainer ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.secondaryContainer ConnectionState.ERROR -> MaterialTheme.colorScheme.errorContainer ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceContainerHigh }, ), ) { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth(), ) { Box( modifier = Modifier .size(10.dp) .clip(CircleShape) .background( color = when (connectionState) { ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.tertiary ConnectionState.ERROR -> MaterialTheme.colorScheme.error ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.outline }, ), ) Spacer(modifier = Modifier.width(10.dp)) Text( text = when (connectionState) { ConnectionState.CONNECTED -> stringResource(R.string.listen_together_connected) ConnectionState.CONNECTING -> stringResource(R.string.listen_together_connecting) ConnectionState.RECONNECTING -> stringResource(R.string.listen_together_reconnecting) ConnectionState.ERROR -> stringResource(R.string.listen_together_error) ConnectionState.DISCONNECTED -> stringResource(R.string.listen_together_disconnected) }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = when (connectionState) { ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primary ConnectionState.CONNECTING, ConnectionState.RECONNECTING -> MaterialTheme.colorScheme.tertiary ConnectionState.ERROR -> MaterialTheme.colorScheme.error ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.onSurfaceVariant }, ) } if (connectionState == ConnectionState.CONNECTING || connectionState == ConnectionState.RECONNECTING) { Spacer(modifier = Modifier.height(12.dp)) LinearProgressIndicator( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)), color = MaterialTheme.colorScheme.primary, ) } Spacer(modifier = Modifier.height(12.dp)) Row( horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), ) { if (connectionState == ConnectionState.DISCONNECTED || connectionState == ConnectionState.ERROR) { Button( onClick = onConnect, modifier = Modifier.weight(1f), shape = RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, ), ) { Icon( painter = painterResource(R.drawable.link), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.connect), fontWeight = FontWeight.SemiBold) } } else { Button( onClick = onDisconnect, modifier = Modifier.weight(1f), shape = RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, ), ) { Text(stringResource(R.string.disconnect), fontWeight = FontWeight.SemiBold) } FilledTonalButton( onClick = onReconnect, modifier = Modifier.weight(1f), shape = RoundedCornerShape(12.dp), ) { Text("Reconnect", fontWeight = FontWeight.SemiBold) } } } } } } @Composable private fun RoomStatusCard( roomCode: String, isHost: Boolean, context: Context, ) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, ), ) { Column( modifier = Modifier .fillMaxWidth() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = stringResource(R.string.room_code), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = roomCode, style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, letterSpacing = 6.sp, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(4.dp)) Text( text = if (isHost) { stringResource(R.string.listen_together_you_are_host) } else { stringResource(R.string.listen_together_you_are_guest) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) if (isHost) { Spacer(modifier = Modifier.height(16.dp)) val inviteLink = remember(roomCode) { "https://metrolist.meowery.eu/listen?code=$roomCode" } Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), modifier = Modifier.fillMaxWidth(), ) { FilledTonalButton( onClick = { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText("Listen Together Link", inviteLink) clipboard.setPrimaryClip(clip) Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() }, shape = RoundedCornerShape(12.dp), ) { Icon( painter = painterResource(R.drawable.link), contentDescription = stringResource(R.string.copy_link), modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.copy_link)) } FilledTonalButton( onClick = { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText("Room Code", roomCode) clipboard.setPrimaryClip(clip) Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() }, shape = RoundedCornerShape(12.dp), ) { Icon( painter = painterResource(R.drawable.content_copy), contentDescription = stringResource(R.string.copy_code), modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.copy_code)) } } } } } } @Composable private fun ConnectedUsersSection( users: List, isHost: Boolean, currentUserId: String, onUserClick: (String, String) -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), ) { Column( modifier = Modifier.padding(16.dp), ) { Text( text = "${stringResource(R.string.connected_users)} (${users.size})", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { users.forEach { user -> UserAvatar( user = user, isCurrentUser = user.userId == currentUserId, isClickable = isHost && user.userId != currentUserId, onClick = { onUserClick(user.userId, user.username) }, ) } } } } } @Composable private fun UserAvatar( user: UserInfo, isCurrentUser: Boolean, isClickable: Boolean, onClick: () -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .width(72.dp) .clickable(enabled = isClickable, onClick = onClick), ) { Box( contentAlignment = Alignment.Center, ) { Surface( modifier = Modifier.size(56.dp), shape = CircleShape, color = when { user.isHost -> MaterialTheme.colorScheme.primary isCurrentUser -> MaterialTheme.colorScheme.secondary else -> MaterialTheme.colorScheme.surfaceVariant }, ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Text( text = user.username.take(1).uppercase(), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = when { user.isHost -> MaterialTheme.colorScheme.onPrimary isCurrentUser -> MaterialTheme.colorScheme.onSecondary else -> MaterialTheme.colorScheme.onSurfaceVariant }, ) } } if (user.isHost || isCurrentUser) { Surface( modifier = Modifier .align(Alignment.BottomEnd) .offset(x = 4.dp, y = 4.dp) .size(20.dp), shape = CircleShape, color = if (user.isHost) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Icon( painter = painterResource( if (user.isHost) R.drawable.crown else R.drawable.person, ), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(12.dp), ) } } } } Spacer(modifier = Modifier.height(8.dp)) Text( text = user.username, style = MaterialTheme.typography.labelMedium, fontWeight = if (isCurrentUser) FontWeight.Bold else FontWeight.Medium, color = if (user.isHost) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, ) if (user.isHost) { Text( text = stringResource(R.string.host_label), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), ) } else if (isCurrentUser) { Text( text = stringResource(R.string.you_label), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.8f), ) } } } @Composable private fun PendingJoinRequestsSection( requests: List, onApprove: (String) -> Unit, onReject: (String) -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, ), ) { Column( modifier = Modifier.padding(16.dp), ) { Text( text = stringResource(R.string.listen_together_join_requests), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(12.dp)) requests.forEach { request -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), ) { Surface( modifier = Modifier.size(40.dp), shape = CircleShape, color = MaterialTheme.colorScheme.secondary, ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Text( text = request.username.take(1).uppercase(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondary, ) } } Spacer(Modifier.width(12.dp)) Text( text = request.username, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), ) MaterialIconButton(onClick = { onApprove(request.userId) }) { Icon( painter = painterResource(R.drawable.check), contentDescription = stringResource(R.string.approve), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) } MaterialIconButton(onClick = { onReject(request.userId) }) { Icon( painter = painterResource(R.drawable.close), contentDescription = stringResource(R.string.reject), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), ) } } } } } } @Composable private fun PendingSuggestionsSection( suggestions: List, onApprove: (String) -> Unit, onReject: (String) -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), ) { Column( modifier = Modifier.padding(16.dp), ) { Text( text = stringResource(R.string.pending_suggestions), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(12.dp)) suggestions.forEach { suggestion -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), ) { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = suggestion.trackInfo.title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = suggestion.fromUsername, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } MaterialIconButton(onClick = { onApprove(suggestion.suggestionId) }) { Icon( painter = painterResource(R.drawable.check), contentDescription = stringResource(R.string.approve), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) } MaterialIconButton(onClick = { onReject(suggestion.suggestionId) }) { Icon( painter = painterResource(R.drawable.close), contentDescription = stringResource(R.string.reject), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), ) } } } } } } @Composable private fun JoinCreateRoomSection( usernameInput: String, onUsernameChange: (String) -> Unit, roomCodeInput: String, onRoomCodeChange: (String) -> Unit, savedUsername: String, isJoiningRoom: Boolean, joinErrorMessage: String?, waitingForApprovalText: String, bringIntoViewRequester: BringIntoViewRequester, onCreateRoom: () -> Unit, onJoinRoom: () -> Unit, onFieldFocused: () -> Unit = {}, ) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, ), ) { Column( modifier = Modifier .fillMaxWidth() .padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { // Username input OutlinedTextField( value = usernameInput, onValueChange = onUsernameChange, label = { Text(stringResource(R.string.username)) }, placeholder = { Text(stringResource(R.string.enter_username)) }, leadingIcon = { Icon( painterResource(R.drawable.person), null, tint = MaterialTheme.colorScheme.primary, ) }, trailingIcon = { if (usernameInput.isNotBlank()) { MaterialIconButton(onClick = { onUsernameChange("") }) { Icon(painterResource(R.drawable.close), null) } } }, singleLine = true, shape = RoundedCornerShape(16.dp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, ), modifier = Modifier .fillMaxWidth() .onFocusChanged { if (it.isFocused) onFieldFocused() }, ) // Room code input OutlinedTextField( value = roomCodeInput, onValueChange = { if (it.length <= 8) onRoomCodeChange(it.uppercase()) }, label = { Text(stringResource(R.string.room_code)) }, placeholder = { Text(stringResource(R.string.enter_room_code)) }, leadingIcon = { Icon( painterResource(R.drawable.group), null, tint = MaterialTheme.colorScheme.primary, ) }, trailingIcon = { if (roomCodeInput.isNotBlank()) { MaterialIconButton(onClick = { onRoomCodeChange("") }) { Icon(painterResource(R.drawable.close), null) } } }, singleLine = true, shape = RoundedCornerShape(16.dp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, ), modifier = Modifier .fillMaxWidth() .bringIntoViewRequester(bringIntoViewRequester) .onFocusChanged { if (it.isFocused) onFieldFocused() }, ) // Waiting for approval indicator AnimatedVisibility( visible = isJoiningRoom, enter = fadeIn() + slideInVertically(), exit = fadeOut() + slideOutVertically(), ) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.primaryContainer, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.width(12.dp)) Text( text = waitingForApprovalText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, ) } } } // Error message AnimatedVisibility( visible = joinErrorMessage != null, enter = fadeIn() + slideInVertically(), exit = fadeOut() + slideOutVertically(), ) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.errorContainer, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Icon( painterResource(R.drawable.error), contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onErrorContainer, ) Spacer(modifier = Modifier.width(12.dp)) Text( text = joinErrorMessage ?: "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, ) } } } // Action buttons val hasUsername = usernameInput.trim().isNotBlank() || savedUsername.isNotBlank() val hasRoomCode = roomCodeInput.length == 8 // Create Room button - visible when username is provided AnimatedVisibility(visible = hasUsername && !hasRoomCode) { Button( onClick = onCreateRoom, modifier = Modifier.fillMaxWidth(), enabled = hasUsername, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, ), ) { Icon( painter = painterResource(R.drawable.add), contentDescription = null, modifier = Modifier.size(20.dp), ) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.create_room), fontWeight = FontWeight.SemiBold) } } // Join Room button - visible when username and room code are provided AnimatedVisibility(visible = hasUsername && hasRoomCode) { Button( onClick = onJoinRoom, modifier = Modifier.fillMaxWidth(), enabled = hasUsername && hasRoomCode, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.tertiary, ), ) { Icon( painter = painterResource(R.drawable.login), contentDescription = null, modifier = Modifier.size(20.dp), ) Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.join_room), fontWeight = FontWeight.SemiBold) } } } } } @Composable private fun SettingsLinkCard(onClick: () -> Unit) { Card( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), ) { Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( painter = painterResource(R.drawable.settings), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.settings), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, ) Text( text = stringResource(R.string.listen_together_settings_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Icon( painter = painterResource(R.drawable.arrow_forward), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) } } } @Composable private fun UserActionDialog( username: String, onKick: () -> Unit, onPermanentKick: () -> Unit, onTransferOwnership: () -> Unit, onDismiss: () -> Unit, ) { DefaultDialog( onDismiss = onDismiss, icon = { Icon( painter = painterResource(R.drawable.group), contentDescription = null, modifier = Modifier.size(28.dp), ) }, title = { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = stringResource(R.string.manage_user), fontWeight = FontWeight.Bold, ) Text( text = username, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, buttons = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { // Kick button Surface( modifier = Modifier .fillMaxWidth() .clickable(onClick = onKick), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.errorContainer, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp), ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.kick_user), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error, ) Text( text = stringResource(R.string.kick_user_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } // Permanently kick button Surface( modifier = Modifier .fillMaxWidth() .clickable(onClick = onPermanentKick), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceVariant, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp), ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.permanently_kick_user), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) Text( text = stringResource(R.string.permanently_kick_user_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } // Transfer ownership button Surface( modifier = Modifier .fillMaxWidth() .clickable(onClick = onTransferOwnership), shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.primaryContainer, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp), ) { Icon( painter = painterResource(R.drawable.crown), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.transfer_ownership), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, ) Text( text = stringResource(R.string.transfer_ownership_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/LoginScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import android.annotation.SuppressLint import android.content.Intent import android.webkit.CookieManager import android.webkit.JavascriptInterface 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.rememberCoroutineScope 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.viewinterop.AndroidView import androidx.navigation.NavController import com.metrolist.innertube.YouTube import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.AccountChannelHandleKey import com.metrolist.music.constants.AccountEmailKey import com.metrolist.music.constants.AccountNameKey import com.metrolist.music.constants.DataSyncIdKey import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.VisitorDataKey import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberPreference import com.metrolist.music.utils.reportException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber @SuppressLint("SetJavaScriptEnabled") @OptIn(ExperimentalMaterial3Api::class, DelicateCoroutinesApi::class) @Composable fun LoginScreen( navController: NavController, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() var visitorData by rememberPreference(VisitorDataKey, "") var dataSyncId by rememberPreference(DataSyncIdKey, "") var innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") var accountName by rememberPreference(AccountNameKey, "") var accountEmail by rememberPreference(AccountEmailKey, "") var accountChannelHandle by rememberPreference(AccountChannelHandleKey, "") var hasCompletedLogin by remember { mutableStateOf(false) } var webView: WebView? = null AndroidView( modifier = Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .fillMaxSize(), factory = { webViewContext -> WebView(webViewContext).apply { webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String?) { loadUrl("javascript:Android.onRetrieveVisitorData(window.yt.config_.VISITOR_DATA)") loadUrl("javascript:Android.onRetrieveDataSyncId(window.yt.config_.DATASYNC_ID)") if (url?.startsWith("https://music.youtube.com") == true && !hasCompletedLogin) { innerTubeCookie = CookieManager.getInstance().getCookie(url) hasCompletedLogin = true coroutineScope.launch { // Small delay to ensure preferences are saved delay(500) // Initialize YouTube object with new authentication data YouTube.cookie = innerTubeCookie YouTube.dataSyncId = dataSyncId YouTube.visitorData = visitorData Timber.d("Login: YouTube object initialized, validating...") YouTube.accountInfo().onSuccess { accountName = it.name accountEmail = it.email.orEmpty() accountChannelHandle = it.channelHandle.orEmpty() Timber.d("Login: Successfully logged in as ${it.name}, restarting app...") // Clean up WebView webView?.apply { stopLoading() clearHistory() clearCache(true) clearFormData() } // Restart app to apply login state throughout val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) context.startActivity(intent) Runtime.getRuntime().exit(0) }.onFailure { Timber.e(it, "Login: Authentication validation failed") hasCompletedLogin = false // Allow retry reportException(it) } } } } } settings.apply { javaScriptEnabled = true setSupportZoom(true) builtInZoomControls = true displayZoomControls = false } addJavascriptInterface(object { @JavascriptInterface fun onRetrieveVisitorData(newVisitorData: String?) { if (newVisitorData != null) { visitorData = newVisitorData } } @JavascriptInterface fun onRetrieveDataSyncId(newDataSyncId: String?) { if (newDataSyncId != null) { dataSyncId = newDataSyncId.substringBefore("||") } } }, "Android") webView = this loadUrl("https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fmusic.youtube.com") } } ) TopAppBar( title = { Text(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() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/MoodAndGenresScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.Spacer import androidx.compose.foundation.layout.asPaddingValues 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.material3.TopAppBar 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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.NavigationTitle import com.metrolist.music.ui.component.shimmer.ListItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.viewmodels.MoodAndGenresViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun MoodAndGenresScreen( navController: NavController, viewModel: MoodAndGenresViewModel = hiltViewModel(), ) { val localConfiguration = LocalConfiguration.current val itemsPerRow = if (localConfiguration.orientation == ORIENTATION_LANDSCAPE) 3 else 2 val moodAndGenresList by viewModel.moodAndGenres.collectAsState() LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { if (moodAndGenresList == null) { item(key = "mood_and_genres_shimmer") { ShimmerHost( modifier = Modifier.animateItem() ) { repeat(8) { ListItemPlaceHolder() } } } } moodAndGenresList?.forEachIndexed { index, moodAndGenres -> item(key = "mood_and_genres_section_$index") { Column( modifier = Modifier .animateItem() .padding(horizontal = 6.dp), ) { NavigationTitle( title = moodAndGenres.title, ) 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)) } } } } } } } TopAppBar( title = { Text(stringResource(R.string.mood_and_genres)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) } @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 ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/NavigationBuilder.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import android.app.Activity 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.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.compose.dialog import androidx.navigation.navArgument import com.metrolist.music.constants.DarkModeKey import com.metrolist.music.constants.PureBlackKey import com.metrolist.music.ui.screens.artist.ArtistAlbumsScreen import com.metrolist.music.ui.screens.artist.ArtistItemsScreen import com.metrolist.music.ui.screens.artist.ArtistScreen import com.metrolist.music.ui.screens.artist.ArtistSongsScreen import com.metrolist.music.ui.screens.equalizer.EqScreen import com.metrolist.music.ui.screens.library.LibraryScreen import com.metrolist.music.ui.screens.playlist.AutoPlaylistScreen import com.metrolist.music.ui.screens.playlist.CachePlaylistScreen import com.metrolist.music.ui.screens.playlist.LocalPlaylistScreen import com.metrolist.music.ui.screens.playlist.OnlinePlaylistScreen import com.metrolist.music.ui.screens.playlist.TopPlaylistScreen import com.metrolist.music.ui.screens.podcast.OnlinePodcastScreen import com.metrolist.music.ui.screens.recognition.RecognitionHistoryScreen import com.metrolist.music.ui.screens.recognition.RecognitionScreen import com.metrolist.music.ui.screens.search.OnlineSearchResult import com.metrolist.music.ui.screens.search.SearchScreen import com.metrolist.music.ui.screens.settings.AboutScreen import com.metrolist.music.ui.screens.settings.AiSettings import com.metrolist.music.ui.screens.settings.AndroidAutoSettings import com.metrolist.music.ui.screens.settings.AppearanceSettings import com.metrolist.music.ui.screens.settings.BackupAndRestore import com.metrolist.music.ui.screens.settings.ContentSettings import com.metrolist.music.ui.screens.settings.DarkMode import com.metrolist.music.ui.screens.settings.DiscordLoginScreen import com.metrolist.music.ui.screens.settings.PlayerSettings import com.metrolist.music.ui.screens.settings.PrivacySettings import com.metrolist.music.ui.screens.settings.RomanizationSettings import com.metrolist.music.ui.screens.settings.SettingsScreen import com.metrolist.music.ui.screens.settings.StorageSettings import com.metrolist.music.ui.screens.settings.ThemeScreen import com.metrolist.music.ui.screens.settings.UpdaterScreen import com.metrolist.music.ui.screens.settings.integrations.DiscordSettings import com.metrolist.music.ui.screens.settings.integrations.IntegrationScreen import com.metrolist.music.ui.screens.settings.integrations.LastFMSettings import com.metrolist.music.ui.screens.settings.integrations.ListenTogetherSettings import com.metrolist.music.ui.screens.wrapped.WrappedScreen import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference @OptIn(ExperimentalMaterial3Api::class) fun NavGraphBuilder.navigationBuilder( navController: NavHostController, scrollBehavior: TopAppBarScrollBehavior, latestVersionName: String, activity: Activity, snackbarHostState: SnackbarHostState, ) { composable(Screens.Home.route) { HomeScreen(navController = navController, snackbarHostState = snackbarHostState) } composable(Screens.Search.route) { val pureBlackEnabled by rememberPreference(PureBlackKey, defaultValue = false) 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 } val pureBlack = remember(pureBlackEnabled, useDarkTheme) { pureBlackEnabled && useDarkTheme } SearchScreen( navController = navController, pureBlack = pureBlack, ) } composable(Screens.Library.route) { LibraryScreen(navController) } composable(Screens.ListenTogether.route) { ListenTogetherScreen(navController, showTopBar = false) } composable( route = "listen_together_from_topbar", ) { ListenTogetherScreen(navController, showTopBar = true) } composable("history") { HistoryScreen(navController) } composable("stats") { StatsScreen(navController) } composable("mood_and_genres") { MoodAndGenresScreen(navController) } composable("account") { AccountScreen(navController) } composable("new_release") { NewReleaseScreen(navController) } composable("charts_screen") { ChartsScreen(navController) } composable( route = "browse/{browseId}", arguments = listOf( navArgument("browseId") { type = NavType.StringType }, ), ) { BrowseScreen( navController, it.arguments?.getString("browseId"), ) } 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) } composable( route = "artist/{artistId}?isPodcastChannel={isPodcastChannel}", arguments = listOf( navArgument("artistId") { type = NavType.StringType }, navArgument("isPodcastChannel") { type = NavType.BoolType defaultValue = false }, ), ) { ArtistScreen(navController) } composable( route = "artist/{artistId}/songs", arguments = listOf( navArgument("artistId") { type = NavType.StringType }, ), ) { ArtistSongsScreen(navController) } composable( route = "artist/{artistId}/albums", arguments = listOf( navArgument("artistId") { type = NavType.StringType }, ), ) { ArtistAlbumsScreen(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) } composable( route = "online_playlist/{playlistId}", arguments = listOf( navArgument("playlistId") { type = NavType.StringType }, ), ) { OnlinePlaylistScreen(navController) } composable( route = "online_podcast/{podcastId}", arguments = listOf( navArgument("podcastId") { type = NavType.StringType }, ), ) { OnlinePodcastScreen(navController, scrollBehavior) } composable( route = "local_playlist/{playlistId}", arguments = listOf( navArgument("playlistId") { type = NavType.StringType }, ), ) { LocalPlaylistScreen(navController) } composable( route = "auto_playlist/{playlist}", arguments = listOf( navArgument("playlist") { type = NavType.StringType }, ), ) { AutoPlaylistScreen(navController) } composable( route = "cache_playlist/{playlist}", arguments = listOf( navArgument("playlist") { type = NavType.StringType }, ), ) { CachePlaylistScreen(navController) } composable( route = "top_playlist/{top}", arguments = listOf( navArgument("top") { type = NavType.StringType }, ), ) { TopPlaylistScreen(navController) } 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") { SettingsScreen(navController, latestVersionName) } composable("settings/appearance") { AppearanceSettings(navController, activity, snackbarHostState) } composable("settings/appearance/theme") { ThemeScreen(navController) } composable("settings/content") { ContentSettings(navController) } composable("settings/content/romanization") { RomanizationSettings(navController) } composable("settings/ai") { AiSettings(navController) } composable("settings/player") { PlayerSettings(navController) } composable("settings/storage") { StorageSettings(navController) } composable("settings/privacy") { PrivacySettings(navController) } composable("settings/backup_restore") { BackupAndRestore(navController) } composable("settings/integrations") { IntegrationScreen(navController) } composable("settings/integrations/discord") { DiscordSettings(navController, snackbarHostState) } composable("settings/integrations/lastfm") { LastFMSettings(navController) } composable(route = "settings/integrations/listen_together") { ListenTogetherSettings(navController) } composable("settings/discord/login") { DiscordLoginScreen(navController) } composable("settings/updater") { UpdaterScreen(navController) } composable("settings/about") { AboutScreen(navController, scrollBehavior) } composable("login") { LoginScreen(navController) } composable("wrapped") { WrappedScreen(navController) } dialog("equalizer") { EqScreen() } composable( route = "recognition?autoStart={autoStart}", arguments = listOf( navArgument("autoStart") { type = NavType.BoolType defaultValue = false }, ), ) { RecognitionScreen(navController, it.arguments?.getBoolean("autoStart") ?: false) } composable("recognition_history") { RecognitionHistoryScreen(navController) } composable("settings/android_auto") { AndroidAutoSettings(navController, scrollBehavior) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/NewReleaseScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.ui.component.shimmer.GridItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.viewmodels.NewReleaseViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun NewReleaseScreen( navController: NavController, viewModel: NewReleaseViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() val coroutineScope = rememberCoroutineScope() val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) LazyVerticalGrid( columns = GridCells.Adaptive(minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp), contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { items( items = newReleaseAlbums.distinctBy { it.id }, 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, ) } }, ), ) } if (newReleaseAlbums.isEmpty()) { items(8) { ShimmerHost { GridItemPlaceHolder(fillMaxWidth = true) } } } } TopAppBar( title = { Text(stringResource(R.string.new_release_albums)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/Screens.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.runtime.Immutable import com.metrolist.music.R @Immutable sealed class Screens( @StringRes val titleId: Int, @DrawableRes val iconIdInactive: Int, @DrawableRes val iconIdActive: Int, val route: String, ) { object Home : Screens( titleId = R.string.home, iconIdInactive = R.drawable.home_outlined, iconIdActive = R.drawable.home_filled, route = "home" ) object Search : Screens( titleId = R.string.search, iconIdInactive = R.drawable.search, iconIdActive = R.drawable.search, route = "search_input" ) object ListenTogether : Screens( titleId = R.string.together, iconIdInactive = R.drawable.group_outlined, iconIdActive = R.drawable.group_filled, route = "listen_together" ) 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, Search, ListenTogether, Library) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/StatsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens import androidx.activity.compose.BackHandler 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.PaddingValues import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding 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.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon 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.remember import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList 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.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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.innertube.models.Artist import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.innertube.utils.parseCookieString import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.CONTENT_TYPE_ARTIST import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.StatPeriod import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.ArtistListItem import com.metrolist.music.ui.component.ChoiceChipsRow import com.metrolist.music.ui.component.EmptyPlaceholder import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalAlbumsGrid import com.metrolist.music.ui.component.LocalArtistsGrid import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.LocalSongsGrid import com.metrolist.music.ui.component.NavigationTitle import com.metrolist.music.ui.component.TimeTransfer import com.metrolist.music.ui.component.PlaylistGridItem import com.metrolist.music.ui.menu.AlbumMenu import com.metrolist.music.ui.menu.ArtistMenu import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.joinByBullet import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.StatsViewModel import java.time.LocalDateTime import java.time.format.DateTimeFormatter @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun StatsScreen( navController: NavController, viewModel: StatsViewModel = hiltViewModel(), ) { val sArtists = viewModel.selectedArtists // SnapshotStateList // Helper actions: val toggleArtistSelection: (Artist) -> Unit = { artist -> if (sArtists.any { it.id == artist.id }) { sArtists.removeAll { it.id == artist.id } } else { sArtists.add(artist) } } val clearArtistSelection: () -> Unit = { sArtists.clear() } val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val context = LocalContext.current var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, Long>( save = { it.toList() }, restore = { it.toMutableStateList() } ) ) { mutableStateListOf() } val onExitSelectionMode = { inSelectMode = false selection.clear() } 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 (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } val indexChips by viewModel.indexChips.collectAsState() val mostPlayedSongs by viewModel.mostPlayedSongs.collectAsState() val mostPlayedSongsStats by viewModel.filteredSongs.collectAsState() val mostPlayedArtists by viewModel.filteredArtists.collectAsState() val mostPlayedAlbums by viewModel.filteredAlbums.collectAsState() val allArtists by viewModel.mostPlayedArtists.collectAsState() val firstEvent by viewModel.firstEvent.collectAsState() val weeklyMostPlaylist by viewModel.weeklyMostPlaylist.collectAsState() val monthlyMostPlaylist by viewModel.monthlyMostPlaylist.collectAsState() val recapPlaylists by viewModel.recapPlaylists.collectAsState() val currentDate = LocalDateTime.now() val orderedMostPlayedSongs = remember(mostPlayedSongsStats, mostPlayedSongs) { val songsById = mostPlayedSongs.associateBy { it.song.id } mostPlayedSongsStats.mapNotNull { statsSong -> songsById[statsSong.id] } } val mostPeriodPlaylists = listOfNotNull(weeklyMostPlaylist, monthlyMostPlaylist) val (innerTubeCookie) = rememberPreference(InnerTubeCookieKey, "") val isLoggedIn = remember(innerTubeCookie) { "SAPISID" in parseCookieString(innerTubeCookie) } val visibleStatsPlaylists = remember(mostPeriodPlaylists, recapPlaylists, isLoggedIn) { if (isLoggedIn) { (mostPeriodPlaylists + recapPlaylists).distinctBy { it.id } } else { mostPeriodPlaylists } } val coroutineScope = rememberCoroutineScope() val lazyListState = rememberLazyListState() val selectedOption by viewModel.selectedOption.collectAsState() var showTimeTransfer by rememberSaveable { mutableStateOf(false) } var prevOptionOrdinal by rememberSaveable { mutableStateOf(null) } var prevIndexChips by rememberSaveable { mutableStateOf(null) } LaunchedEffect(showTimeTransfer) { if (showTimeTransfer) { if (prevOptionOrdinal == null) prevOptionOrdinal = selectedOption if (prevIndexChips == null) prevIndexChips = indexChips viewModel.selectedOption.value = OptionStats.CONTINUOUS // "throughout time" in your VM viewModel.indexChips.value = StatPeriod.ALL.ordinal // optional: ensure it’s actually “now -> throughout time” } } if (showTimeTransfer) { TimeTransfer( onDismiss = { showTimeTransfer = false prevOptionOrdinal?.let { viewModel.selectedOption.value = it } prevIndexChips?.let { viewModel.indexChips.value = it } // Clear snapshots for the next open prevOptionOrdinal = null prevIndexChips = null }, ) } LaunchedEffect(Unit) { viewModel.syncMostPlaylistsIfNeeded() } 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()) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) .asPaddingValues(), modifier = Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top), ), ) { val filteredArtists = allArtists.map { artistWrapper -> Artist( id = artistWrapper.artist.id, name = artistWrapper.artist.name, ) }.filter { artist -> artist.name.contains(query.text, ignoreCase = true) } item(key = "choice_chips") { 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 }, ) } if (visibleStatsPlaylists.isNotEmpty() && !isSearching && sArtists.isEmpty()) { item(key = "mostPeriodPlaylistsTitle") { NavigationTitle( title = pluralStringResource( R.plurals.n_playlist, visibleStatsPlaylists.size, visibleStatsPlaylists.size, ), modifier = Modifier.animateItem(), ) } item(key = "mostPeriodPlaylists") { LazyRow( contentPadding = PaddingValues(horizontal = 4.dp), modifier = Modifier.animateItem(), ) { itemsIndexed( items = visibleStatsPlaylists, key = { _, playlist -> playlist.id }, ) { _, playlist -> PlaylistGridItem( playlist = playlist, autoPlaylist = true, modifier = Modifier .combinedClickable( onClick = { navController.navigate("local_playlist/${playlist.id}") }, ).animateItem(), ) } } } } if (!isSearching) { item(key = "mostPlayedSongs") { NavigationTitle( title = "${mostPlayedSongsStats.size} ${stringResource(id = R.string.songs)}", onPlayAllClick = if (orderedMostPlayedSongs.isNotEmpty()) { { playerConnection.playQueue( ListQueue( title = context.getString(R.string.most_played_songs), items = orderedMostPlayedSongs.map { it.toMediaMetadata().toMediaItem() }, ) ) } } else { null }, 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.togglePlayPause() } else { val targetSong = mostPlayedSongs.find { it.id == song.id } if (targetSong != null) { playerConnection.playQueue( YouTubeQueue( endpoint = WatchEndpoint(song.id), preloadItem = targetSong.toMediaMetadata(), ), ) } } }, onLongClick = { val targetSong = mostPlayedSongs.find { it.id == song.id } if (targetSong != null) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { SongMenu( originalSong = targetSong, navController = navController, onDismiss = menuState::dismiss, ) } } }, ) .animateItem(), ) } } } } if (!isSearching) { 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(), ) } } } } if (!isSearching) { 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 ?: 0, album.songCountListened ?: 0, ), makeTimeString(album.timeListened), ), 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(), ) } } } } } if (isSearching) { items( items = allArtists.filter { artist -> artist.artist.name.contains(query.text, ignoreCase = true) }, key = { it.id }, contentType = { CONTENT_TYPE_ARTIST }, ) { artist -> val uiArtist = Artist(name = artist.artist.name, id = artist.id) val isChecked = sArtists.any { it.id == uiArtist.id } Row( // Use a row to arrange the checkbox and ArtistListItem horizontally verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { toggleArtistSelection(uiArtist) } .padding(8.dp) ) { ArtistListItem( artist = artist, modifier = Modifier.weight(1f) // Allow ArtistListItem to take remaining space ) Checkbox( checked = sArtists.contains(Artist(name = artist.artist.name, id = artist.id)), // Get the current checked state onCheckedChange = { toggleArtistSelection(uiArtist) } ) } } } if (query.text.isNotEmpty() && filteredArtists.isEmpty()) { item(key = "no_result") { EmptyPlaceholder( icon = R.drawable.search, text = stringResource(R.string.no_results_found), ) } } } // FAB to shuffle most played songs if (mostPlayedSongsStats.isNotEmpty() && !isSearching) { HideOnScrollFAB( visible = true, lazyListState = lazyListState, icon = R.drawable.shuffle, onClick = { playerConnection.playQueue( ListQueue( title = context.getString(R.string.most_played_songs), items = orderedMostPlayedSongs.map { it.toMediaMetadata().toMediaItem() }.shuffled(), ), ) }, ) } } TopAppBar( title = { if (inSelectMode) { Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) } else if (isSearching) { Row { 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 .weight(1f) .focusRequester(focusRequester) ) if (sArtists.isNotEmpty()) { androidx.compose.material3.IconButton(onClick = clearArtistSelection) { Icon( painter = painterResource(R.drawable.close), contentDescription = "Clear Artists", tint = MaterialTheme.colorScheme.onSurface ) } } } } else { Text(stringResource(R.string.stats)) } }, navigationIcon = { if (inSelectMode) { androidx.compose.material3.IconButton(onClick = onExitSelectionMode) { Icon( painter = painterResource(R.drawable.close), contentDescription = "Select Button", ) } } else { IconButton( onClick = { if (isSearching) { isSearching = false query = TextFieldValue() } else { navController.navigateUp() } }, onLongClick = { if (!isSearching) { navController.backToMain() } } ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = "Back Button" ) } } }, actions = { if (inSelectMode) { Checkbox( checked = true, onCheckedChange = { } ) androidx.compose.material3.IconButton( enabled = selection.isNotEmpty(), onClick = { } ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = "More Button" ) } } else if (!isSearching) { androidx.compose.material3.IconButton( onClick = { isSearching = true } ) { Icon( painter = painterResource(R.drawable.search), contentDescription = "Search Button" ) } IconButton( onClick = {showTimeTransfer = true}, onLongClick = {showTimeTransfer = true}, ) { Icon( painterResource(R.drawable.sync), contentDescription = "Time Transfer", ) } } } ) } enum class OptionStats { WEEKS, MONTHS, YEARS, CONTINUOUS } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/YouTubeBrowseScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.ui.component.shimmer.GridItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.menu.YouTubeArtistMenu import com.metrolist.music.ui.menu.YouTubePlaylistMenu import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.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.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val browseResult by viewModel.result.collectAsState() val coroutineScope = rememberCoroutineScope() val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) val allItems = browseResult?.items?.flatMap { it.items } ?: emptyList() LazyVerticalGrid( columns = GridCells.Adaptive(minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp), contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { if (browseResult == null) { items(8) { ShimmerHost { GridItemPlaceHolder(fillMaxWidth = true) } } } items( items = allItems.distinctBy { it.id }, 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 -> { if (item.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue.radio(item.toMediaMetadata()) ) } } is AlbumItem -> navController.navigate("album/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") is PlaylistItem -> navController.navigate("online_playlist/${item.id}") is PodcastItem -> navController.navigate("online_podcast/${item.id}") is EpisodeItem -> { if (item.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue.radio(item.toMediaMetadata()) ) } } } }, 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, ) is PodcastItem -> YouTubePlaylistMenu( playlist = item.asPlaylistItem(), coroutineScope = coroutineScope, onDismiss = menuState::dismiss, ) is EpisodeItem -> YouTubeSongMenu( song = item.asSongItem(), navController = navController, 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 ) } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistAlbumsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.artist import androidx.activity.compose.BackHandler 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.windowInsetsPadding 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.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState 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.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.CONTENT_TYPE_ALBUM import com.metrolist.music.constants.CONTENT_TYPE_HEADER import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LibraryAlbumGridItem import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.viewmodels.ArtistAlbumsViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ArtistAlbumsScreen( navController: NavController, scrollBehavior: TopAppBarScrollBehavior, viewModel: ArtistAlbumsViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val artist by viewModel.artist.collectAsState() val albums by viewModel.albums.collectAsState() val coroutineScope = rememberCoroutineScope() val lazyGridState = rememberLazyGridState() val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, String>( save = { it.toList() }, restore = { it.toMutableStateList() } ) ) { mutableStateListOf() } val onExitSelectionMode = { inSelectMode = false selection.clear() } if (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } val snackbarHostState = remember { SnackbarHostState() } Box( modifier = Modifier.fillMaxSize() ) { LazyVerticalGrid( state = lazyGridState, columns = GridCells.Adaptive(minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp), contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { item( key = "header", span = { GridItemSpan(maxLineSpan) }, contentType = CONTENT_TYPE_HEADER ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 16.dp) ) { Spacer(Modifier.weight(1f)) Text( text = pluralStringResource(R.plurals.n_album, albums.size, albums.size), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary ) } } items( items = albums.distinctBy { it.id }, 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() ) } } TopAppBar( title = { Text(artist?.artist?.name.orEmpty()) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain ) { Icon( painter = painterResource(id = R.drawable.arrow_back), contentDescription = null ) } }, scrollBehavior = scrollBehavior ) SnackbarHost( hostState = snackbarHostState, modifier = Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .align(Alignment.BottomCenter) ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistItemsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.component.shimmer.GridItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ListItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.menu.YouTubeArtistMenu import com.metrolist.music.ui.menu.YouTubePlaylistMenu import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.viewmodels.ArtistItemsViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ArtistItemsScreen( navController: NavController, viewModel: ArtistItemsViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val lazyListState = rememberLazyListState() val lazyGridState = rememberLazyGridState() val coroutineScope = rememberCoroutineScope() val gridItemSize by rememberEnumPreference(GridItemsSizeKey, GridItemSize.BIG) 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().distinctBy { it.id }, 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, ) is PodcastItem -> YouTubePlaylistMenu( playlist = item.asPlaylistItem(), coroutineScope = coroutineScope, onDismiss = menuState::dismiss, ) is EpisodeItem -> YouTubeSongMenu( song = item.asSongItem(), navController = navController, 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.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}") is PodcastItem -> navController.navigate("online_podcast/${item.id}") is EpisodeItem -> { if (item.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue( item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata() ), ) } } } }, ) } if (itemsPage?.continuation != null) { item(key = "loading") { ShimmerHost { repeat(3) { ListItemPlaceHolder() } } } } } } else { LazyVerticalGrid( state = lazyGridState, columns = GridCells.Adaptive(minSize = GridThumbnailHeight + if (gridItemSize == GridItemSize.BIG) 24.dp else (-24).dp), contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { items( items = itemsPage?.items.orEmpty().distinctBy { it.id }, 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}") is PodcastItem -> navController.navigate("online_podcast/${item.id}") is EpisodeItem -> playerConnection.playQueue( YouTubeQueue( item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata() ) ) } }, 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 ) is PodcastItem -> YouTubePlaylistMenu( playlist = item.asPlaylistItem(), coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) is EpisodeItem -> YouTubeSongMenu( song = item.asSongItem(), navController = navController, 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, ) } }, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.artist import android.content.ClipData import android.content.ClipboardManager import android.content.Context 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.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides 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.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.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape 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.TopAppBar import androidx.compose.material3.TopAppBarDefaults 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.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalResources 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.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEach import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.AppBarHeight import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.ShowArtistDescriptionKey import com.metrolist.music.constants.ShowArtistSubscriberCountKey import com.metrolist.music.constants.ShowMonthlyListenersKey import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.AlbumGridItem import com.metrolist.music.ui.component.ExpandableText import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LinkSegment import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.NavigationTitle import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.YouTubeGridItem import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.component.shimmer.ButtonPlaceholder import com.metrolist.music.ui.component.shimmer.ListItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.component.shimmer.TextPlaceholder import com.metrolist.music.ui.menu.AlbumMenu import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.menu.YouTubeArtistMenu import com.metrolist.music.ui.menu.YouTubePlaylistMenu import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.ui.utils.fadingEdge import com.metrolist.music.ui.utils.isScrollingUp import com.metrolist.music.ui.utils.resize import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.ArtistViewModel import com.valentinilk.shimmer.shimmer import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ArtistScreen( navController: NavController, 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 listenTogetherManager = LocalListenTogetherManager.current val isGuest = listenTogetherManager?.isInRoom == true && !listenTogetherManager.isHost val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val artistPage = viewModel.artistPage val libraryArtist by viewModel.libraryArtist.collectAsState() val librarySongs by viewModel.librarySongs.collectAsState() val libraryAlbums by viewModel.libraryAlbums.collectAsState() val isChannelSubscribed by viewModel.isChannelSubscribed.collectAsState() val hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) val showArtistDescription by rememberPreference(key = ShowArtistDescriptionKey, defaultValue = true) val showArtistSubscriberCount by rememberPreference(key = ShowArtistSubscriberCountKey, defaultValue = true) val showMonthlyListeners by rememberPreference(key = ShowMonthlyListenersKey, defaultValue = true) val lazyListState = rememberLazyListState() val snackbarHostState = remember { SnackbarHostState() } var showLocal by rememberSaveable { mutableStateOf(false) } val density = LocalDensity.current // Calculate the offset value outside of the offset lambda val systemBarsTopPadding = WindowInsets.systemBars.asPaddingValues().calculateTopPadding() val headerOffset = with(density) { -(systemBarsTopPadding + AppBarHeight).roundToPx() } val transparentAppBar by remember { derivedStateOf { lazyListState.firstVisibleItemIndex == 0 && lazyListState.firstVisibleItemScrollOffset < 100 } } LaunchedEffect(libraryArtist) { // always show local page for local artists. Show local page remote artist when offline showLocal = libraryArtist?.artist?.isLocal == true } Box( modifier = Modifier.fillMaxSize(), ) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { if (artistPage == null && !showLocal) { item(key = "shimmer") { ShimmerHost( modifier = Modifier .offset { IntOffset(x = 0, y = headerOffset) }, ) { // Artist Image Placeholder Box( modifier = Modifier .fillMaxWidth() .aspectRatio(1.1f), ) { Spacer( modifier = Modifier .fillMaxSize() .shimmer() .background(MaterialTheme.colorScheme.onSurface) .fadingEdge( top = systemBarsTopPadding + AppBarHeight, bottom = 200.dp, ), ) } // Artist Name and Controls Section Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { // Artist Name Placeholder TextPlaceholder( height = 36.dp, modifier = Modifier .fillMaxWidth(0.7f) .padding(bottom = 16.dp), ) // Buttons Row Placeholder Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { // Subscribe Button Placeholder ButtonPlaceholder( modifier = Modifier .width(120.dp) .height(40.dp), ) Spacer(modifier = Modifier.weight(1f)) // Right side buttons Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { // Radio Button Placeholder ButtonPlaceholder( modifier = Modifier .width(100.dp) .height(40.dp), ) // Shuffle Button Placeholder Box( modifier = Modifier .size(48.dp) .shimmer() .background( MaterialTheme.colorScheme.onSurface, RoundedCornerShape(24.dp), ), ) } } } // Songs List Placeholder repeat(6) { ListItemPlaceHolder() } } } } else { item(key = "header") { val thumbnail = artistPage?.artist?.thumbnail ?: libraryArtist?.artist?.thumbnailUrl val artistName = artistPage?.artist?.title ?: libraryArtist?.artist?.name Box { // Artist Image with offset if (thumbnail != null) { Box( modifier = Modifier .fillMaxWidth() .aspectRatio(1f) .offset { IntOffset(x = 0, y = headerOffset) }, ) { AsyncImage( model = thumbnail.resize(1200, 1200), contentDescription = null, modifier = Modifier .fillMaxWidth() .align(Alignment.TopCenter) .fadingEdge( bottom = 200.dp, ), ) } } // Artist Name and Controls Section - positioned at bottom of image Column( modifier = Modifier .fillMaxWidth() .padding( top = if (thumbnail != null) { // Position content at the bottom part of the image // Using screen width to calculate aspect ratio height minus overlap LocalResources.current.displayMetrics.widthPixels.let { screenWidth -> with(density) { ((screenWidth / 1.2f) - 144).toDp() } } } else { 16.dp }, ), ) { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) { // Artist Name Text( text = artistName ?: "Unknown", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 32.sp, modifier = Modifier.padding(bottom = 16.dp), ) // Buttons Row Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { // Subscribe Button OutlinedButton( onClick = { viewModel.toggleChannelSubscription() }, colors = ButtonDefaults.outlinedButtonColors( containerColor = if (isChannelSubscribed) { MaterialTheme.colorScheme.surface } else { Color.Transparent }, ), shape = RoundedCornerShape(50), modifier = Modifier.height(40.dp), ) { Text( text = stringResource(if (isChannelSubscribed) R.string.subscribed else R.string.subscribe), fontSize = 14.sp, color = if (!isChannelSubscribed) MaterialTheme.colorScheme.error else LocalContentColor.current, ) } Spacer(modifier = Modifier.weight(1f)) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { // Radio Button if (!showLocal && !isGuest) { artistPage?.artist?.radioEndpoint?.let { radioEndpoint -> OutlinedButton( onClick = { playerConnection.playQueue(YouTubeQueue(radioEndpoint)) }, shape = RoundedCornerShape(50), modifier = Modifier.height(40.dp), ) { Icon( painter = painterResource(R.drawable.radio), contentDescription = null, modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text( text = stringResource(R.string.radio), fontSize = 14.sp, ) } } } // Shuffle Button if (!showLocal && !isGuest) { artistPage?.artist?.shuffleEndpoint?.let { shuffleEndpoint -> IconButton( onClick = { playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) }, modifier = Modifier .size(48.dp) .background( MaterialTheme.colorScheme.primary, RoundedCornerShape(24.dp), ), ) { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = "Shuffle", tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(20.dp), ) } } } } } } Spacer(modifier = Modifier.height(16.dp)) } } } // About Artist Section if (!showLocal && (showArtistDescription || showArtistSubscriberCount || showMonthlyListeners)) { val description = artistPage?.description val descriptionRuns = artistPage?.descriptionRuns val subscriberCount = artistPage?.subscriberCountText val monthlyListeners = artistPage?.monthlyListenerCount if ((showArtistDescription && !description.isNullOrEmpty()) || (showArtistSubscriberCount && !subscriberCount.isNullOrEmpty()) || (showMonthlyListeners && !monthlyListeners.isNullOrEmpty()) ) { item(key = "about_artist") { Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) .padding(bottom = 16.dp) .animateItem(), ) { if (showArtistDescription && (!description.isNullOrEmpty() || !descriptionRuns.isNullOrEmpty())) { Text( text = stringResource(R.string.about_artist), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp), ) } if (showArtistSubscriberCount && !subscriberCount.isNullOrEmpty()) { Text( text = subscriberCount, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 4.dp), ) } if (showMonthlyListeners && !monthlyListeners.isNullOrEmpty()) { Text( text = monthlyListeners, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding( bottom = if (showArtistDescription && !description.isNullOrEmpty() ) { 8.dp } else { 0.dp }, ), ) } if (showArtistDescription && (!description.isNullOrEmpty() || !descriptionRuns.isNullOrEmpty())) { ExpandableText( text = description.orEmpty(), runs = descriptionRuns?.map { LinkSegment( text = it.text, url = it.navigationEndpoint?.urlEndpoint?.url, ) }, collapsedMaxLines = 3, ) } } } } } if (showLocal) { if (librarySongs.isNotEmpty()) { item(key = "local_songs_title") { NavigationTitle( title = stringResource(R.string.songs), modifier = Modifier.animateItem(), onClick = { navController.navigate("artist/${viewModel.artistId}/songs") }, ) } val filteredLibrarySongs = if (hideExplicit) { librarySongs.filter { !it.song.explicit } } else { librarySongs } itemsIndexed( items = filteredLibrarySongs, key = { index, item -> "local_song_${item.id}_$index" }, ) { index, 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 (!isGuest) { if (song.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( ListQueue( title = libraryArtist?.artist?.name ?: "Unknown Artist", items = librarySongs.map { it.toMediaItem() }, startIndex = index, ), ) } } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { SongMenu( originalSong = song, navController = navController, onDismiss = menuState::dismiss, ) } }, ).animateItem(), ) } } if (libraryAlbums.isNotEmpty()) { item(key = "local_albums_title") { NavigationTitle( title = stringResource(R.string.albums), modifier = Modifier.animateItem(), onClick = { navController.navigate("artist/${viewModel.artistId}/albums") }, ) } item(key = "local_albums_list") { val filteredLibraryAlbums = if (hideExplicit) { libraryAlbums.filter { !it.album.explicit } } else { libraryAlbums } LazyRow( contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues(), ) { items( items = filteredLibraryAlbums, key = { "local_album_${it.id}_${filteredLibraryAlbums.indexOf(it)}" }, ) { album -> AlbumGridItem( album = album, isActive = mediaMetadata?.album?.id == album.id, isPlaying = isPlaying, coroutineScope = coroutineScope, modifier = Modifier .combinedClickable( onClick = { navController.navigate("album/${album.id}") }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { AlbumMenu( originalAlbum = album, navController = navController, onDismiss = menuState::dismiss, ) } }, ).animateItem(), ) } } } } } else { artistPage?.sections?.fastForEach { section -> if (section.items.isNotEmpty()) { item(key = "section_${section.title}") { NavigationTitle( title = section.title, modifier = Modifier.animateItem(), 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.distinctBy { it.id }, key = { "youtube_song_${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 (!isGuest) { if (song.id == mediaMetadata?.id) { playerConnection.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(key = "section_list_${section.title}") { LazyRow( contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues(), ) { items( items = section.items.distinctBy { it.id }, key = { "youtube_album_${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, thumbnailRatio = 1f, // Use square thumbnails for all items in horizontal scroll modifier = Modifier .combinedClickable( onClick = { when (item) { is SongItem -> { if (!isGuest) { 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}") } is PodcastItem -> { navController.navigate("online_podcast/${item.id}") } is EpisodeItem -> { if (!isGuest) { playerConnection.playQueue( YouTubeQueue( WatchEndpoint(videoId = item.id), item.toMediaMetadata(), ), ) } } } }, 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, ) } is PodcastItem -> { YouTubePlaylistMenu( playlist = item.asPlaylistItem(), coroutineScope = coroutineScope, onDismiss = menuState::dismiss, ) } is EpisodeItem -> { YouTubeSongMenu( song = item.asSongItem(), navController = navController, onDismiss = menuState::dismiss, ) } } } }, ).animateItem(), ) } } } } } } } } val isScrollingUp = lazyListState.isScrollingUp() val showLocalFab = librarySongs.isNotEmpty() && libraryArtist?.artist?.isLocal != true // Library/Local Toggle FAB HideOnScrollFAB( visible = showLocalFab, lazyListState = lazyListState, icon = if (showLocal) R.drawable.language else R.drawable.library_music, onClick = { showLocal = showLocal.not() if (!showLocal && artistPage == null) viewModel.fetchArtistsFromYTM() }, ) // Play All FAB (Stacked above Library/Local FAB if visible) val canPlayAll = !isGuest && ( (showLocal && librarySongs.isNotEmpty()) || ( !showLocal && artistPage?.sections?.any { (it.items.firstOrNull() as? SongItem)?.album != null } == true ) ) if (canPlayAll) { androidx.compose.animation.AnimatedVisibility( visible = isScrollingUp, enter = androidx.compose.animation.slideInVertically { it * 2 }, exit = androidx.compose.animation.slideOutVertically { it * 2 }, modifier = Modifier .align(Alignment.BottomEnd) .windowInsetsPadding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), ) // Add padding to position it above the other FAB (56dp height + 16dp padding + 8dp spacing) // If the other FAB is visible. .padding(bottom = if (showLocalFab) 64.dp else 0.dp), ) { val onPlayAllClick: () -> Unit = { if (!isGuest) { if (showLocal) { if (librarySongs.isNotEmpty()) { playerConnection.playQueue( ListQueue( title = libraryArtist?.artist?.name ?: "Unknown Artist", items = librarySongs.map { it.toMediaItem() }, ), ) } } else if (artistPage != null) { val songSection = artistPage.sections.find { section -> (section.items.firstOrNull() as? SongItem)?.album != null } val moreEndpoint = songSection?.moreEndpoint if (moreEndpoint != null) { coroutineScope.launch(kotlinx.coroutines.Dispatchers.IO) { val result = YouTube.artistItems(moreEndpoint).getOrNull() withContext(kotlinx.coroutines.Dispatchers.Main) { if (result != null && result.items.isNotEmpty()) { val songs = result.items.filterIsInstance().map { it.toMediaItem() } playerConnection.playQueue( ListQueue( title = artistPage.artist.title, items = songs, ), ) } else { // Fallback to loaded items val songs = songSection.items.filterIsInstance().map { it.toMediaItem() } if (songs.isNotEmpty()) { playerConnection.playQueue( ListQueue( title = artistPage.artist.title, items = songs, ), ) } } } } } else if (songSection != null) { // Use loaded items if no more endpoint val songs = songSection.items.filterIsInstance().map { it.toMediaItem() } playerConnection.playQueue( ListQueue( title = artistPage.artist.title, items = songs, ), ) } else { // Fallback to shuffle endpoint (stripped) if no song section found val shuffleEndpoint = artistPage.artist.shuffleEndpoint if (shuffleEndpoint != null) { val endpoint = if (shuffleEndpoint.playlistId != null) { WatchEndpoint( playlistId = shuffleEndpoint.playlistId, params = null, // Remove shuffle params to play in order videoId = null, // Ensure videoId is null to start from beginning of playlist ) } else { shuffleEndpoint } playerConnection.playQueue(YouTubeQueue(endpoint)) } } } } } if (showLocalFab) { androidx.compose.material3.SmallFloatingActionButton( modifier = Modifier.padding(16.dp).offset(x = (-4).dp), // Align center with standard FAB (56dp vs 48dp) onClick = onPlayAllClick, ) { Icon( painter = painterResource(R.drawable.play), contentDescription = "Play All", ) } } else { androidx.compose.material3.FloatingActionButton( modifier = Modifier.padding(16.dp), onClick = onPlayAllClick, ) { Icon( painter = painterResource(R.drawable.play), contentDescription = "Play All", modifier = Modifier.size(32.dp), ) } } } } SnackbarHost( hostState = snackbarHostState, modifier = Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .align(Alignment.BottomCenter), ) } TopAppBar( title = { if (!transparentAppBar) Text(artistPage?.artist?.title.orEmpty()) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, actions = { IconButton( onClick = { viewModel.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( painterResource(R.drawable.link), contentDescription = null, ) } }, colors = if (transparentAppBar) { TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) } else { TopAppBarDefaults.topAppBarColors() }, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/artist/ArtistSongsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.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.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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.ArtistSongSortDescendingKey import com.metrolist.music.constants.ArtistSongSortType import com.metrolist.music.constants.ArtistSongSortTypeKey import com.metrolist.music.constants.CONTENT_TYPE_HEADER import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.ArtistSongsViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ArtistSongsScreen( navController: NavController, viewModel: ArtistSongsViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val queueAllSongsStr = stringResource(R.string.queue_all_songs) val haptic = LocalHapticFeedback.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val (sortType, onSortTypeChange) = rememberEnumPreference( ArtistSongSortTypeKey, ArtistSongSortType.CREATE_DATE, ) val (sortDescending, onSortDescendingChange) = rememberPreference( ArtistSongSortDescendingKey, true, ) val hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) val artist by viewModel.artist.collectAsState() val songs by viewModel.songs.collectAsState() val lazyListState = rememberLazyListState() Box( modifier = Modifier.fillMaxSize(), ) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { 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, songs.size, songs.size), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary, ) } } itemsIndexed( items = songs, key = { _, item -> item.id }, ) { index, 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.togglePlayPause() } else { playerConnection.playQueue( ListQueue( title = queueAllSongsStr, items = songs.map { it.toMediaItem() }, startIndex = index, ), ) } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { SongMenu( originalSong = song, navController = navController, onDismiss = menuState::dismiss, ) } }, ).animateItem(), ) } } TopAppBar( title = { Text(artist?.artist?.name.orEmpty()) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) HideOnScrollFAB( lazyListState = lazyListState, icon = R.drawable.shuffle, onClick = { playerConnection.playQueue( ListQueue( title = artist?.artist?.name, items = songs.shuffled().map { it.toMediaItem() }, ), ) }, ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/equalizer/EQState.kt ================================================ package com.metrolist.music.ui.screens.equalizer import com.metrolist.music.eq.data.SavedEQProfile /** * UI State for EQ Screen */ data class EQState( val profiles: List = emptyList(), val activeProfileId: String? = null, val importStatus: String? = null, val error: String? = null ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/equalizer/EQViewModel.kt ================================================ package com.metrolist.music.ui.screens.equalizer import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.music.eq.EqualizerService import com.metrolist.music.eq.data.EQProfileRepository import com.metrolist.music.eq.data.ParametricEQParser import dagger.hilt.android.lifecycle.HiltViewModel 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 java.io.InputStream import javax.inject.Inject /** * ViewModel for EQ Screen * Manages EQ profiles and applies them to the EqualizerService */ @HiltViewModel class EQViewModel @Inject constructor( private val eqProfileRepository: EQProfileRepository, private val equalizerService: EqualizerService ) : ViewModel() { private val _state = MutableStateFlow(EQState()) val state: StateFlow = _state.asStateFlow() init { loadProfiles() } /** * Load all saved EQ profiles (sorted: AutoEQ first, then custom) */ private fun loadProfiles() { // Observe profiles changes viewModelScope.launch { eqProfileRepository.profiles.collect { _ -> val sortedProfiles = eqProfileRepository.getSortedProfiles() _state.update { it.copy(profiles = sortedProfiles) } } } // Observe active profile changes separately viewModelScope.launch { eqProfileRepository.activeProfile.collect { activeProfile -> _state.update { it.copy(activeProfileId = activeProfile?.id) } } } } /** * Select and apply an EQ profile * Pass null to disable EQ */ fun selectProfile(profileId: String?) { viewModelScope.launch { if (profileId == null) { // Disable EQ equalizerService.disable() eqProfileRepository.setActiveProfile(null) } else { // Apply the selected profile val profile = _state.value.profiles.find { it.id == profileId } if (profile != null) { val result = equalizerService.applyProfile(profile) result.onSuccess { eqProfileRepository.setActiveProfile(profileId) }.onFailure { e -> _state.update { it.copy(error = e.message ?: "Unknown error") } } } } } } /** * Clear error message */ fun clearError() { _state.update { it.copy(error = null) } } /** * Delete an EQ profile */ fun deleteProfile(profileId: String) { viewModelScope.launch { eqProfileRepository.deleteProfile(profileId) } } /** * Import a custom EQ profile from a file */ fun importCustomProfile( fileName: String, inputStream: InputStream, onSuccess: () -> Unit, onError: (Exception) -> Unit ) { viewModelScope.launch { try { // Read the file content val content = inputStream.bufferedReader().use { it.readText() } inputStream.close() // Parse the ParametricEQ format val parametricEQ = ParametricEQParser.parseText(content) // Validate the parsed EQ val validationErrors = ParametricEQParser.validate(parametricEQ) if (validationErrors.isNotEmpty()) { onError(Exception("Invalid EQ file: ${validationErrors.first()}")) return@launch } // Extract profile name from file name (remove .txt extension) val profileName = fileName.removeSuffix(".txt") // Import the profile eqProfileRepository.importCustomProfile(profileName, parametricEQ) _state.update { it.copy(importStatus = "Successfully imported $profileName") } onSuccess() } catch (e: Exception) { onError(Exception("Failed to import EQ profile: ${e.message}")) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/equalizer/EqScreen.kt ================================================ package com.metrolist.music.ui.screens.equalizer import android.annotation.SuppressLint import android.content.Intent import android.media.audiofx.AudioEffect import android.media.session.PlaybackState import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton 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.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.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.eq.data.SavedEQProfile import timber.log.Timber /** * EQ Screen - Manage and select EQ profiles */ @SuppressLint("LocalContextGetResourceValueCall") @Composable fun EqScreen( viewModel: EQViewModel = hiltViewModel(), playbackState: PlaybackState? = null ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current val playerConnection = LocalPlayerConnection.current var showError by remember { mutableStateOf(null) } // Activity result launcher for system equalizer val activityResultLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { } // File picker for custom EQ import val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri -> if (uri != null) { try { val contentResolver = context.contentResolver // Extract file name from URI var fileName = "custom_eq.txt" contentResolver.query(uri, null, null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (displayNameIndex >= 0) { val name = cursor.getString(displayNameIndex) if (!name.isNullOrBlank()) { fileName = name } } } } val inputStream = contentResolver.openInputStream(uri) if (inputStream != null) { viewModel.importCustomProfile( fileName = fileName, inputStream = inputStream, onSuccess = { Timber.d("Custom EQ profile imported successfully: $fileName") }, onError = { error -> Timber.d("Error: Unable to import Custom EQ profile: $fileName") showError = context.getString(R.string.import_error_title) + ": " + error.message }) } else { showError = context.getString(R.string.error_file_read) } } catch (e: Exception) { showError = context.getString(R.string.error_file_open, e.message) } } } EqScreenContent( profiles = state.profiles, activeProfileId = state.activeProfileId, onProfileSelected = { viewModel.selectProfile(it) }, onImportCustomEQ = { // Launch file picker for .txt files filePickerLauncher.launch("text/plain") }, onOpenSystemEqualizer = { playerConnection?.let { connection -> val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { putExtra( AudioEffect.EXTRA_AUDIO_SESSION, connection.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) } } }, onDeleteProfile = { viewModel.deleteProfile(it) } ) // Error dialog if (showError != null) { AlertDialog( onDismissRequest = { showError = null }, title = { Text(stringResource(R.string.import_error_title)) }, text = { Text(showError ?: "") }, confirmButton = { TextButton(onClick = { showError = null }) { Text(stringResource(android.R.string.ok)) } } ) } // Error dialog for apply failure if (state.error != null) { AlertDialog( onDismissRequest = { viewModel.clearError() }, title = { Text(stringResource(R.string.error_title)) }, text = { Text(stringResource(R.string.error_eq_apply_failed, state.error ?: "")) }, confirmButton = { TextButton(onClick = { viewModel.clearError() }) { Text(stringResource(android.R.string.ok)) } } ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EqScreenContent( profiles: List, activeProfileId: String?, onProfileSelected: (String?) -> Unit, onImportCustomEQ: () -> Unit, onOpenSystemEqualizer: () -> Unit, onDeleteProfile: (String) -> Unit ) { Surface( shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 6.dp, modifier = Modifier .fillMaxWidth(0.9f) .heightIn(max = 600.dp) .padding(vertical = 24.dp) // Optional extra padding if desired, but dialog handles it. ) { Column { // Header Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Column { Text( text = stringResource(R.string.equalizer_header), style = MaterialTheme.typography.headlineSmall ) Text( text = pluralStringResource( id = R.plurals.profiles_count, count = profiles.size, profiles.size ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Row { IconButton(onClick = onImportCustomEQ) { Icon( painter = painterResource(R.drawable.add), contentDescription = stringResource(R.string.import_profile) ) } IconButton(onClick = onOpenSystemEqualizer) { Icon( painter = painterResource(R.drawable.equalizer), contentDescription = stringResource(R.string.system_equalizer) ) } } } // Profile list LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(bottom = 16.dp) ) { // "No Equalization" option (always first) item { NoEqualizationItem( isSelected = activeProfileId == null, onSelected = { onProfileSelected(null) } ) } // Custom profiles only val customProfiles = profiles.filter { it.isCustom } if (customProfiles.isNotEmpty()) { items(customProfiles) { profile -> EQProfileItem( profile = profile, isSelected = activeProfileId == profile.id, onSelected = { onProfileSelected(profile.id) }, onDelete = { onDeleteProfile(profile.id) } ) } } // Empty state if (customProfiles.isEmpty()) { item { Box( modifier = Modifier .fillMaxWidth() .padding(32.dp), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(R.drawable.equalizer), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.no_profiles), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) Button(onClick = onImportCustomEQ) { Text(stringResource(R.string.import_profile)) } Spacer(modifier = Modifier.height(8.dp)) OutlinedButton(onClick = onOpenSystemEqualizer) { Text(stringResource(R.string.system_equalizer)) } } } } } } } } } // --- HELPER COMPOSABLES --- @Composable private fun NoEqualizationItem( isSelected: Boolean, onSelected: () -> Unit ) { ListItem( headlineContent = { Text( stringResource(R.string.eq_disabled), fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal ) }, leadingContent = { RadioButton( selected = isSelected, onClick = onSelected ) }, modifier = Modifier .clickable(onClick = onSelected) .padding(horizontal = 8.dp) // align with design ) } @Composable private fun EQProfileItem( profile: SavedEQProfile, isSelected: Boolean, onSelected: () -> Unit, onDelete: () -> Unit ) { var showDeleteDialog by remember { mutableStateOf(false) } ListItem( headlineContent = { Text( text = profile.deviceModel, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal ) }, supportingContent = { Text( pluralStringResource( id = R.plurals.band_count, count = profile.bands.size, profile.bands.size ) ) }, leadingContent = { RadioButton( selected = isSelected, onClick = onSelected ) }, trailingContent = { IconButton(onClick = { showDeleteDialog = true }) { Icon( painter = painterResource(R.drawable.delete), contentDescription = stringResource(R.string.delete_profile_desc), tint = MaterialTheme.colorScheme.error ) } }, modifier = Modifier .clickable(onClick = onSelected) .padding(horizontal = 8.dp) ) // Delete confirmation dialog if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, title = { Text(stringResource(R.string.delete_profile_desc)) }, text = { Text( stringResource(R.string.delete_profile_confirmation, profile.name) ) }, confirmButton = { TextButton( onClick = { onDelete() showDeleteDialog = false } ) { Text(stringResource(android.R.string.ok)) } }, dismissButton = { TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(android.R.string.cancel)) } } ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryAlbumsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.ExperimentalMaterial3Api 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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.AlbumFilter import com.metrolist.music.constants.AlbumFilterKey import com.metrolist.music.constants.AlbumSortDescendingKey import com.metrolist.music.constants.AlbumSortType import com.metrolist.music.constants.AlbumSortTypeKey import com.metrolist.music.constants.AlbumViewTypeKey import com.metrolist.music.constants.CONTENT_TYPE_ALBUM import com.metrolist.music.constants.CONTENT_TYPE_HEADER import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.LibraryViewType import com.metrolist.music.constants.YtmSyncKey import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.ui.component.EmptyPlaceholder import com.metrolist.music.ui.component.LibraryAlbumGridItem import com.metrolist.music.ui.component.LibraryAlbumListItem import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.LibraryAlbumsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun LibraryAlbumsScreen( navController: NavController, onDeselect: () -> Unit, viewModel: LibraryAlbumsViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.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 hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) 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), AlbumFilter.UPLOADED to stringResource(R.string.filter_uploaded) ), currentValue = filter, onValueUpdate = { filter = it }, modifier = Modifier.weight(1f), ) } } LaunchedEffect(Unit) { if (ytmSync) { withContext(Dispatchers.IO) { viewModel.sync() } } } val albums by viewModel.allAlbums.collectAsState() 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(key = "empty_placeholder") { EmptyPlaceholder( icon = R.drawable.album, text = stringResource(R.string.library_album_empty), modifier = Modifier.animateItem() ) } } val filteredAlbumsForList = if (hideExplicit) { albums.filter { !it.album.explicit } } else { albums } items( items = filteredAlbumsForList.distinctBy { it.id }, 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() ) } } val filteredAlbumsForGrid = if (hideExplicit) { albums.filter { !it.album.explicit } } else { albums } items( items = filteredAlbumsForGrid.distinctBy { it.id }, 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() ) } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryArtistsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.ExperimentalMaterial3Api 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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.ArtistFilter import com.metrolist.music.constants.ArtistFilterKey import com.metrolist.music.constants.ArtistSortDescendingKey import com.metrolist.music.constants.ArtistSortType import com.metrolist.music.constants.ArtistSortTypeKey import com.metrolist.music.constants.ArtistViewTypeKey import com.metrolist.music.constants.CONTENT_TYPE_ARTIST import com.metrolist.music.constants.CONTENT_TYPE_HEADER import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.constants.LibraryViewType import com.metrolist.music.constants.YtmSyncKey import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.ui.component.EmptyPlaceholder import com.metrolist.music.ui.component.LibraryArtistGridItem import com.metrolist.music.ui.component.LibraryArtistListItem import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.LibraryArtistsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun LibraryArtistsScreen( navController: NavController, onDeselect: () -> Unit, viewModel: LibraryArtistsViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() 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(Unit) { if (ytmSync) { withContext(Dispatchers.IO) { viewModel.sync() } } } val artists by viewModel.allArtists.collectAsState() 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(key = "empty_placeholder") { EmptyPlaceholder( icon = R.drawable.artist, text = stringResource(R.string.library_artist_empty), modifier = Modifier.animateItem() ) } } items( items = artists.distinctBy { it.id }, 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.distinctBy { it.id }, key = { it.id }, contentType = { CONTENT_TYPE_ARTIST }, ) { artist -> LibraryArtistGridItem( navController = navController, menuState = menuState, coroutineScope = coroutineScope, modifier = Modifier.animateItem(), artist = artist ) } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryMixScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.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.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.AlbumViewTypeKey import com.metrolist.music.constants.CONTENT_TYPE_HEADER import com.metrolist.music.constants.CONTENT_TYPE_PLAYLIST import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.constants.LibraryViewType import com.metrolist.music.constants.MixSortDescendingKey import com.metrolist.music.constants.MixSortType import com.metrolist.music.constants.MixSortTypeKey import com.metrolist.music.constants.MixSortTypeKey import com.metrolist.music.constants.ShowCachedPlaylistKey import com.metrolist.music.constants.ShowDownloadedPlaylistKey import com.metrolist.music.constants.ShowLikedPlaylistKey import com.metrolist.music.constants.ShowTopPlaylistKey import com.metrolist.music.constants.ShowUploadedPlaylistKey import com.metrolist.music.constants.YtmSyncKey import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Artist import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.extensions.reversed import com.metrolist.music.ui.component.AlbumGridItem import com.metrolist.music.ui.component.AlbumListItem import com.metrolist.music.ui.component.ArtistGridItem import com.metrolist.music.ui.component.ArtistListItem import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.PlaylistGridItem import com.metrolist.music.ui.component.PlaylistListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.ui.menu.AlbumMenu import com.metrolist.music.ui.menu.ArtistMenu import com.metrolist.music.ui.menu.PlaylistMenu import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.LibraryMixViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.text.Collator import java.time.LocalDateTime import java.util.Locale import java.util.UUID import androidx.compose.ui.platform.LocalLocale @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun LibraryMixScreen( navController: NavController, filterContent: @Composable () -> Unit, viewModel: LibraryMixViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.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 (ytmSync) = rememberPreference(YtmSyncKey, true) val topSize by viewModel.topValue.collectAsState(initial = 50) val likedPlaylist = Playlist( playlist = PlaylistEntity( id = UUID.randomUUID().toString(), name = stringResource(R.string.liked) ), songCount = 0, songThumbnails = emptyList(), ) val downloadPlaylist = Playlist( playlist = PlaylistEntity( id = UUID.randomUUID().toString(), name = stringResource(R.string.offline) ), songCount = 0, songThumbnails = emptyList(), ) val topPlaylist = Playlist( playlist = PlaylistEntity( id = UUID.randomUUID().toString(), name = stringResource(R.string.my_top) + " $topSize" ), songCount = 0, songThumbnails = emptyList(), ) val uploadedPlaylist = Playlist( playlist = PlaylistEntity( id = UUID.randomUUID().toString(), name = stringResource(R.string.uploaded_playlist) ), songCount = 0, songThumbnails = emptyList(), ) val cachedPlaylist = Playlist( playlist = PlaylistEntity( id = UUID.randomUUID().toString(), name = stringResource(R.string.cached_playlist) ), songCount = 0, songThumbnails = emptyList(), ) val (showLiked) = rememberPreference(ShowLikedPlaylistKey, true) val (showDownloaded) = rememberPreference(ShowDownloadedPlaylistKey, true) val (showTop) = rememberPreference(ShowTopPlaylistKey, true) val (showUploaded) = rememberPreference(ShowUploadedPlaylistKey, true) val (showCached) = rememberPreference(ShowCachedPlaylistKey, true) 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(LocalLocale.current.platformLocale) 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) } } LaunchedEffect(Unit) { if (ytmSync) { withContext(Dispatchers.IO) { viewModel.syncAllLibrary() } } } 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, ) } } } val isRefreshing by viewModel.isRefreshing.collectAsState() val pullRefreshState = rememberPullToRefreshState() Box( modifier = Modifier .fillMaxSize() .pullToRefresh( state = pullRefreshState, isRefreshing = isRefreshing, onRefresh = viewModel::refresh ), ) { 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() } if (showLiked) { item( key = "likedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = likedPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("auto_playlist/liked") } .animateItem(), ) } } if (showDownloaded) { item( key = "downloadedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = downloadPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("auto_playlist/downloaded") } .animateItem(), ) } } if (showCached) { item( key = "cachedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = cachedPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("cache_playlist/cached") } .animateItem(), ) } } if (showTop) { item( key = "TopPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = topPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("top_playlist/$topSize") } .animateItem(), ) } } if (showUploaded) { item( key = "uploadedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = uploadedPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("auto_playlist/uploaded") } .animateItem(), ) } } items( items = allItems.distinctBy { it.id }, 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 = { if (!item.playlist.isEditable && item.songCount == 0 && item.playlist.browseId != null) navController.navigate("online_playlist/${item.playlist.browseId}") else 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() } if (showLiked) { 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(), ) } } if (showDownloaded) { 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(), ) } } if (showCached) { item( key = "cachedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistGridItem( playlist = cachedPlaylist, fillMaxWidth = true, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = { navController.navigate("cache_playlist/cached") }, ) .animateItem(), ) } } if (showTop) { 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(), ) } } if (showUploaded) { item( key = "uploadedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistGridItem( playlist = uploadedPlaylist, fillMaxWidth = true, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("auto_playlist/uploaded") } .animateItem(), ) } } items( items = allItems.distinctBy { it.id }, key = { it.id }, contentType = { CONTENT_TYPE_PLAYLIST }, ) { item -> when (item) { is Playlist -> { PlaylistGridItem( playlist = item, fillMaxWidth = true, modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = { if (!item.playlist.isEditable && item.songCount == 0 && item.playlist.browseId != null) navController.navigate("online_playlist/${item.playlist.browseId}") else navController.navigate("local_playlist/${item.id}") }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { PlaylistMenu( playlist = item, coroutineScope = coroutineScope, onDismiss = menuState::dismiss, ) } }, ) .animateItem(), ) } 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 -> {} } } } } Indicator( isRefreshing = isRefreshing, state = pullRefreshState, modifier = Modifier .align(Alignment.TopCenter) .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()), ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryPlaylistsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.metrolist.innertube.utils.parseCookieString import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.CONTENT_TYPE_HEADER import com.metrolist.music.constants.CONTENT_TYPE_PLAYLIST import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.GridThumbnailHeight import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.LibraryViewType import com.metrolist.music.constants.PlaylistSortDescendingKey import com.metrolist.music.constants.PlaylistSortType import com.metrolist.music.constants.PlaylistSortTypeKey import com.metrolist.music.constants.PlaylistViewTypeKey import com.metrolist.music.constants.ShowCachedPlaylistKey import com.metrolist.music.constants.ShowDownloadedPlaylistKey import com.metrolist.music.constants.ShowLikedPlaylistKey import com.metrolist.music.constants.ShowTopPlaylistKey import com.metrolist.music.constants.ShowUploadedPlaylistKey import com.metrolist.music.constants.YtmSyncKey import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.ui.component.CreatePlaylistDialog import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.LibraryPlaylistGridItem import com.metrolist.music.ui.component.LibraryPlaylistListItem import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.PlaylistGridItem import com.metrolist.music.ui.component.PlaylistListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.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, viewModel: LibraryPlaylistsViewModel = hiltViewModel(), initialTextFieldValue: String? = null, allowSyncing: Boolean = true, ) { val menuState = LocalMenuState.current val haptic = 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, songThumbnails = emptyList(), ) val downloadPlaylist = Playlist( playlist = PlaylistEntity( id = UUID.randomUUID().toString(), name = stringResource(R.string.offline) ), songCount = 0, songThumbnails = emptyList(), ) val topPlaylist = Playlist( playlist = PlaylistEntity( id = UUID.randomUUID().toString(), name = stringResource(R.string.my_top) + " $topSize" ), songCount = 0, songThumbnails = emptyList(), ) val uploadedPlaylist = Playlist( playlist = PlaylistEntity( id = UUID.randomUUID().toString(), name = stringResource(R.string.uploaded_playlist) ), songCount = 0, songThumbnails = emptyList(), ) val cachedPlaylist = Playlist( playlist = PlaylistEntity( id = UUID.randomUUID().toString(), name = stringResource(R.string.cached_playlist) ), songCount = 0, songThumbnails = emptyList(), ) val (showLiked) = rememberPreference(ShowLikedPlaylistKey, true) val (showDownloaded) = rememberPreference(ShowDownloadedPlaylistKey, true) val (showTop) = rememberPreference(ShowTopPlaylistKey, true) val (showUploaded) = rememberPreference(ShowUploadedPlaylistKey, true) val (showCached) = rememberPreference(ShowCachedPlaylistKey, true) val lazyListState = rememberLazyListState() val lazyGridState = rememberLazyGridState() val backStackEntry by navController.currentBackStackEntryAsState() val scrollToTop = backStackEntry?.savedStateHandle?.getStateFlow("scrollToTop", false)?.collectAsState() val (innerTubeCookie) = rememberPreference(InnerTubeCookieKey, "") val isLoggedIn = 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, onPlaylistCreated = { playlistId -> showCreatePlaylistDialog = false navController.navigate("local_playlist/$playlistId") } ) } 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() } if (showLiked) { item( key = "likedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = likedPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("auto_playlist/liked") } .animateItem(), ) } } if (showDownloaded) { item( key = "downloadedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = downloadPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("auto_playlist/downloaded") } .animateItem(), ) } } if (showCached) { item( key = "cachedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = cachedPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("cache_playlist/cached") } .animateItem(), ) } } if (showTop) { item( key = "TopPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = topPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("top_playlist/$topSize") } .animateItem(), ) } } if (showUploaded) { item( key = "uploadedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistListItem( playlist = uploadedPlaylist, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("auto_playlist/uploaded") } .animateItem(), ) } } playlists.let { playlists -> if (playlists.isEmpty()) { item(key = "empty_placeholder") { } } items( items = playlists.distinctBy { it.id }, 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() } if (showLiked) { 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(), ) } } if (showDownloaded) { 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(), ) } } if (showCached) { item( key = "cachedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistGridItem( playlist = cachedPlaylist, fillMaxWidth = true, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = { navController.navigate("cache_playlist/cached") }, ) .animateItem(), ) } } if (showTop) { 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(), ) } } if (showUploaded) { item( key = "uploadedPlaylist", contentType = { CONTENT_TYPE_PLAYLIST }, ) { PlaylistGridItem( playlist = uploadedPlaylist, fillMaxWidth = true, autoPlaylist = true, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("auto_playlist/uploaded") } .animateItem(), ) } } playlists.let { playlists -> if (playlists.isEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { } } items( items = playlists.distinctBy { it.id }, key = { it.id }, contentType = { CONTENT_TYPE_PLAYLIST }, ) { playlist -> LibraryPlaylistGridItem( navController = navController, menuState = menuState, coroutineScope = coroutineScope, playlist = playlist, modifier = Modifier.animateItem() ) } } } HideOnScrollFAB( lazyListState = lazyGridState, icon = R.drawable.add, onClick = { showCreatePlaylistDialog = true }, ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryPodcastsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.library import android.content.Intent import androidx.compose.foundation.ExperimentalFoundationApi 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.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.itemsIndexed 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.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.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.DisposableEffect 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.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import coil3.compose.AsyncImage import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.LocalSyncUtils import com.metrolist.music.R import com.metrolist.music.constants.CONTENT_TYPE_HEADER import com.metrolist.music.constants.CONTENT_TYPE_SONG import com.metrolist.music.constants.PodcastFilter import com.metrolist.music.constants.PodcastFilterKey import com.metrolist.music.constants.SongSortDescendingKey import com.metrolist.music.constants.SongSortType import com.metrolist.music.constants.SongSortTypeKey import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.PodcastEntity import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.Material3MenuGroup import com.metrolist.music.ui.component.Material3MenuItemData import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.utils.joinByBullet import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.LibraryPodcastsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun LibraryPodcastsScreen( navController: NavController, onDeselect: () -> Unit, viewModel: LibraryPodcastsViewModel = hiltViewModel(), ) { val downloadedEpisodesStr = stringResource(R.string.downloaded_episodes) val database = LocalDatabase.current val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() var podcastFilter by rememberEnumPreference(PodcastFilterKey, PodcastFilter.EPISODES) val (sortType, onSortTypeChange) = rememberEnumPreference( SongSortTypeKey, SongSortType.CREATE_DATE, ) val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) val subscribedChannels by viewModel.subscribedChannels.collectAsState() val downloadedEpisodes by viewModel.downloadedEpisodes.collectAsState() val savedEpisodes by viewModel.savedEpisodes.collectAsState() val sePlaylist by viewModel.sePlaylist.collectAsState() val podcastChannels by viewModel.podcastChannels.collectAsState() val rdpnPlaylist by viewModel.rdpnPlaylist.collectAsState() // Refresh channels when screen becomes visible (ON_RESUME) val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { viewModel.refreshChannels() } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } 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) } } var isRefreshing by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val pullToRefreshState = rememberPullToRefreshState() Box( modifier = Modifier .fillMaxSize() .pullToRefresh( state = pullToRefreshState, isRefreshing = isRefreshing, onRefresh = { if (!isRefreshing) { isRefreshing = true coroutineScope.launch { viewModel.refreshAll() isRefreshing = false } } }, ), ) { // Chip row header — same pattern as LibrarySongsScreen val chipsHeader = @Composable { Row { Spacer(Modifier.width(12.dp)) FilterChip( label = { Text(stringResource(R.string.filter_podcasts)) }, selected = true, colors = FilterChipDefaults.filterChipColors( containerColor = MaterialTheme.colorScheme.surface, ), onClick = onDeselect, shape = RoundedCornerShape(16.dp), border = null, leadingIcon = { Icon( painter = painterResource(R.drawable.close), contentDescription = null, ) }, ) ChipsRow( chips = listOf( PodcastFilter.EPISODES to stringResource(R.string.filter_episodes), PodcastFilter.CHANNELS to stringResource(R.string.filter_channels), PodcastFilter.DOWNLOADED to stringResource(R.string.filter_downloaded), ), currentValue = podcastFilter, onValueUpdate = { podcastFilter = it }, modifier = Modifier.weight(1f), ) } } when (podcastFilter) { // ── EPISODES FOR LATER tab ──────────────────────────────────── PodcastFilter.EPISODES -> { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { item(key = "filter", contentType = CONTENT_TYPE_HEADER) { chipsHeader() } // RDPN "New Episodes" auto-playlist card item(key = "rdpn_playlist", contentType = CONTENT_TYPE_HEADER) { AutoPlaylistCard( title = stringResource(R.string.new_episodes), thumbnailUrl = rdpnPlaylist?.thumbnail, episodeCount = rdpnPlaylist?.songCountText, onClick = { navController.navigate("online_playlist/RDPN") }, ) } // Episodes for Later - card/folder (works both logged in and out) item(key = "episodes_for_later", contentType = CONTENT_TYPE_HEADER) { AutoPlaylistCard( title = stringResource(R.string.episodes_for_later), thumbnailUrl = sePlaylist?.thumbnail ?: savedEpisodes.firstOrNull()?.song?.thumbnailUrl, episodeCount = sePlaylist?.songCountText ?: if (savedEpisodes.isNotEmpty()) { pluralStringResource(R.plurals.n_episode, savedEpisodes.size, savedEpisodes.size) } else { null }, onClick = { navController.navigate("online_playlist/SE") }, ) } // Saved podcast shows (episode playlists) from YT Music library itemsIndexed( items = subscribedChannels, key = { _, item -> item.id }, contentType = { _, _ -> CONTENT_TYPE_SONG }, ) { _, podcast -> PodcastEpisodePlaylistItem( podcast = podcast, onClick = { navController.navigate("online_podcast/${podcast.id}") }, onMenuClick = { menuState.show { PodcastEpisodePlaylistMenu( podcast = podcast, database = database, onDismiss = menuState::dismiss, ) } }, modifier = Modifier .fillMaxWidth() .animateItem(), ) } } } // ── CHANNELS tab — podcast host artist pages from YT Music ─── PodcastFilter.CHANNELS -> { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { item(key = "filter", contentType = CONTENT_TYPE_HEADER) { chipsHeader() } item(key = "channels_count", contentType = CONTENT_TYPE_HEADER) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), ) { Text( text = pluralStringResource( R.plurals.n_channel, podcastChannels.size, podcastChannels.size, ), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary, ) } } itemsIndexed( items = podcastChannels, key = { _, item -> item.id }, contentType = { _, _ -> CONTENT_TYPE_SONG }, ) { _, channel -> PodcastArtistChannelItem( thumbnailUrl = channel.thumbnail, channelName = channel.title, modifier = Modifier .fillMaxWidth() .clickable { navController.navigate("artist/${channel.id}") }.animateItem(), ) } if (podcastChannels.isEmpty()) { item(key = "empty") { Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 48.dp), contentAlignment = Alignment.Center, ) { Text( text = stringResource(R.string.no_subscribed_channels), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } } // ── DOWNLOADED tab ──────────────────────────────────────────── PodcastFilter.DOWNLOADED -> { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { item(key = "filter", contentType = CONTENT_TYPE_HEADER) { chipsHeader() } item(key = "sort_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 = { st -> when (st) { 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_episode, downloadedEpisodes.size, downloadedEpisodes.size, ), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary, ) } } itemsIndexed( items = downloadedEpisodes, key = { _, item -> item.song.id }, contentType = { _, _ -> CONTENT_TYPE_SONG }, ) { index, episode -> // Always show channel name: use artists if available, // else fall back to song.albumName (podcast show title stored during sync) val channelName = episode.artists .joinToString { it.name } .ifEmpty { episode.song.albumName ?: "" } val subtitle = joinByBullet( channelName, makeTimeString(episode.song.duration.toLong() * 1000L), ) SongListItem( song = episode, showInLibraryIcon = false, isActive = episode.id == mediaMetadata?.id, isPlaying = isPlaying, showLikedIcon = false, showDownloadIcon = true, subtitleOverride = subtitle.ifEmpty { null }, trailingContent = { IconButton( onClick = { menuState.show { SongMenu( originalSong = episode, navController = navController, onDismiss = menuState::dismiss, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, ) } }, modifier = Modifier .fillMaxWidth() .clickable { if (episode.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( ListQueue( title = downloadedEpisodesStr, items = downloadedEpisodes.map { it.toMediaItem() }, startIndex = index, ), ) } }.animateItem(), ) } if (downloadedEpisodes.isEmpty()) { item(key = "empty") { Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 48.dp), contentAlignment = Alignment.Center, ) { Text( text = stringResource(R.string.no_downloaded_episodes), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } HideOnScrollFAB( visible = downloadedEpisodes.isNotEmpty(), lazyListState = lazyListState, icon = R.drawable.shuffle, onClick = { playerConnection.playQueue( ListQueue( title = downloadedEpisodesStr, items = downloadedEpisodes.shuffled().map { it.toMediaItem() }, ), ) }, ) } } Indicator( isRefreshing = isRefreshing, state = pullToRefreshState, modifier = Modifier .align(Alignment.TopCenter) .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()), ) } } /** Auto-playlist card — mirrors YT Music design. Used for both SE and RDPN playlists. */ @Composable private fun AutoPlaylistCard( title: String, thumbnailUrl: String?, episodeCount: String?, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 10.dp), ) { Box( modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(ThumbnailCornerRadius)) .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center, ) { if (thumbnailUrl != null) { AsyncImage( model = thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(ThumbnailCornerRadius)), ) } else { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(28.dp), ) } } Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = buildString { append(stringResource(R.string.auto_playlist)) if (!episodeCount.isNullOrBlank()) { append(" • ") append(episodeCount) } }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } } /** Episode playlist row shown in the Episodes tab — represents a saved podcast show */ @Composable private fun PodcastEpisodePlaylistItem( podcast: PodcastEntity, onClick: () -> Unit, onMenuClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 8.dp), ) { Box( modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(ThumbnailCornerRadius)) .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center, ) { if (podcast.thumbnailUrl != null) { AsyncImage( model = podcast.thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(56.dp) .clip(RoundedCornerShape(ThumbnailCornerRadius)), ) } else { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(28.dp), ) } } Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = podcast.title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, ) if (!podcast.author.isNullOrBlank()) { Text( text = podcast.author, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } IconButton(onClick = onMenuClick) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, ) } } } /** Menu shown when tapping the three-dot icon on an episode playlist */ @Composable private fun PodcastEpisodePlaylistMenu( podcast: PodcastEntity, database: MusicDatabase, onDismiss: () -> Unit, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val syncUtils = LocalSyncUtils.current val isPinned by database.speedDialDao.isPinned(podcast.id).collectAsState(initial = false) val playlistId = podcast.id.removePrefix("MPSP") val shareUrl = "https://music.youtube.com/playlist?list=$playlistId" Spacer(Modifier.height(12.dp)) Material3MenuGroup( items = listOf( Material3MenuItemData( title = { Text(text = stringResource(R.string.remove_from_library)) }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { // Update local database database.query { update(podcast.copy(bookmarkedAt = null)) } // Sync with YouTube (unsave podcast only, don't unsubscribe channel) syncUtils.savePodcast(podcast.id, false) } onDismiss() }, ), Material3MenuItemData( title = { Text(text = stringResource(R.string.share)) }, icon = { Icon( painter = painterResource(R.drawable.share), contentDescription = null, ) }, onClick = { val intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, shareUrl) } context.startActivity(Intent.createChooser(intent, null)) onDismiss() }, ), Material3MenuItemData( title = { Text( text = stringResource( if (isPinned) { R.string.unpin_from_speed_dial } else { R.string.pin_to_speed_dial }, ), ) }, icon = { Icon( painter = painterResource( if (isPinned) R.drawable.remove else R.drawable.add, ), contentDescription = null, ) }, onClick = { coroutineScope.launch(Dispatchers.IO) { if (isPinned) { database.speedDialDao.delete(podcast.id) } else { database.speedDialDao.insert( SpeedDialItem( id = podcast.id, title = podcast.title, subtitle = podcast.author, thumbnailUrl = podcast.thumbnailUrl, type = "PLAYLIST", ), ) } } onDismiss() }, ), ), ) Spacer(Modifier.height(12.dp)) } /** Artist/channel page item shown in the Channels tab */ @Composable private fun PodcastArtistChannelItem( thumbnailUrl: String?, channelName: String, modifier: Modifier = Modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) { AsyncImage( model = thumbnailUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(56.dp) .clip(CircleShape), ) Spacer(Modifier.width(12.dp)) Text( text = channelName, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibraryScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.library import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import com.metrolist.music.R import com.metrolist.music.constants.ChipSortTypeKey import com.metrolist.music.constants.LibraryFilter import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.utils.rememberEnumPreference @Composable fun LibraryScreen(navController: NavController) { var filterType by rememberEnumPreference(ChipSortTypeKey, LibraryFilter.LIBRARY) 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.PODCASTS to stringResource(R.string.filter_podcasts), ), currentValue = filterType, onValueUpdate = { filterType = if (filterType == it) LibraryFilter.LIBRARY else it }, modifier = Modifier.weight(1f), ) } } Box(modifier = Modifier.fillMaxSize()) { when (filterType) { LibraryFilter.LIBRARY -> LibraryMixScreen(navController, filterContent) LibraryFilter.PLAYLISTS -> LibraryPlaylistsScreen(navController, filterContent) LibraryFilter.SONGS -> LibrarySongsScreen( navController, { filterType = LibraryFilter.LIBRARY }, ) LibraryFilter.ALBUMS -> LibraryAlbumsScreen( navController, { filterType = LibraryFilter.LIBRARY }, ) LibraryFilter.ARTISTS -> LibraryArtistsScreen( navController, { filterType = LibraryFilter.LIBRARY }, ) LibraryFilter.PODCASTS -> LibraryPodcastsScreen( navController, { filterType = LibraryFilter.LIBRARY }, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/library/LibrarySongsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.library import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi 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.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.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.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator 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.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.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.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.metrolist.innertube.YouTube import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.CONTENT_TYPE_HEADER import com.metrolist.music.constants.CONTENT_TYPE_SONG import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.SongFilter import com.metrolist.music.constants.SongFilterKey import com.metrolist.music.constants.SongSortDescendingKey import com.metrolist.music.constants.SongSortType import com.metrolist.music.constants.SongSortTypeKey import com.metrolist.music.constants.YtmSyncKey import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.utils.isScrollingUp import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.LibrarySongsViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun LibrarySongsScreen( navController: NavController, onDeselect: () -> Unit, viewModel: LibrarySongsViewModel = hiltViewModel(), ) { val context = LocalContext.current val menuState = LocalMenuState.current val uploadUnsupportedFormatStr = stringResource(R.string.upload_unsupported_format) val uploadFileTooLargeStr = stringResource(R.string.upload_file_too_large) val uploadFailedStr = stringResource(R.string.upload_failed) val uploadCompleteStr = stringResource(R.string.upload_complete) val queueAllSongsStr = stringResource(R.string.queue_all_songs) val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val scope = rememberCoroutineScope() val (sortType, onSortTypeChange) = rememberEnumPreference( SongSortTypeKey, SongSortType.CREATE_DATE, ) val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) val (ytmSync) = rememberPreference(YtmSyncKey, true) val hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) val songs by viewModel.allSongs.collectAsState() var filter by rememberEnumPreference(SongFilterKey, SongFilter.LIKED) // Upload state var showUploadDialog by remember { mutableStateOf(false) } var uploadProgress by remember { mutableFloatStateOf(0f) } var currentUploadIndex by remember { mutableIntStateOf(0) } var totalUploads by remember { mutableIntStateOf(0) } var currentFileName by remember { mutableStateOf("") } var isUploading by remember { mutableStateOf(false) } var uploadJob by remember { mutableStateOf(null) } val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenMultipleDocuments(), ) { uris: List -> if (uris.isNotEmpty()) { uploadJob = scope.launch { isUploading = true showUploadDialog = true totalUploads = uris.size var successCount = 0 uris.forEachIndexed { index, uri -> currentUploadIndex = index + 1 uploadProgress = 0f try { val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "unknown" currentFileName = fileName val extension = fileName.substringAfterLast('.', "").lowercase() if (extension !in YouTube.SUPPORTED_UPLOAD_TYPES) { withContext(Dispatchers.Main) { Toast .makeText( context, uploadUnsupportedFormatStr, Toast.LENGTH_SHORT, ).show() } return@forEachIndexed } val inputStream = context.contentResolver.openInputStream(uri) val data = inputStream?.readBytes() inputStream?.close() if (data == null) return@forEachIndexed if (data.size > YouTube.MAX_UPLOAD_SIZE) { withContext(Dispatchers.Main) { Toast .makeText( context, uploadFileTooLargeStr, Toast.LENGTH_SHORT, ).show() } return@forEachIndexed } val result = YouTube.uploadSong( filename = fileName, data = data, onProgress = { progress -> uploadProgress = progress }, ) if (result.isSuccess && result.getOrDefault(false)) { successCount++ } } catch (e: Exception) { withContext(Dispatchers.Main) { Toast .makeText( context, uploadFailedStr + ": ${e.message}", Toast.LENGTH_SHORT, ).show() } } } isUploading = false if (successCount > 0) { // Show completion briefly uploadProgress = 1f currentFileName = uploadCompleteStr kotlinx.coroutines.delay(1000) // Show toast on main thread withContext(Dispatchers.Main) { Toast .makeText( context, uploadCompleteStr, Toast.LENGTH_SHORT, ).show() } showUploadDialog = false // Refresh uploaded songs viewModel.syncUploadedSongs() } else { showUploadDialog = false } } } } LaunchedEffect(Unit) { if (ytmSync) { when (filter) { SongFilter.LIKED -> viewModel.syncLikedSongs() SongFilter.LIBRARY -> viewModel.syncLibrarySongs() SongFilter.UPLOADED -> viewModel.syncUploadedSongs() else -> return@LaunchedEffect } } } 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 filteredSongs = if (hideExplicit) { songs.filter { !it.song.explicit } } else { songs } // Upload progress dialog if (showUploadDialog) { DefaultDialog( onDismiss = { if (isUploading) { uploadJob?.cancel() isUploading = false } showUploadDialog = false }, icon = { Icon( painter = painterResource(R.drawable.upload), contentDescription = null, ) }, title = { Text(stringResource(R.string.uploading)) }, buttons = { TextButton( onClick = { if (isUploading) { uploadJob?.cancel() isUploading = false } showUploadDialog = false }, ) { Text(stringResource(R.string.cancel)) } }, ) { Text( text = stringResource(R.string.upload_progress, currentUploadIndex, totalUploads), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = currentFileName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(16.dp)) LinearProgressIndicator( progress = { uploadProgress }, modifier = Modifier.fillMaxWidth(), ) } } Box( modifier = Modifier.fillMaxSize(), ) { 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.UPLOADED to stringResource(R.string.filter_uploaded), 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, 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, filteredSongs.size, filteredSongs.size, ), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.secondary, ) } } itemsIndexed( items = filteredSongs, key = { _, item -> item.song.id }, contentType = { _, _ -> CONTENT_TYPE_SONG }, ) { index, song -> SongListItem( song = song, showInLibraryIcon = true, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, showLikedIcon = true, showDownloadIcon = filter != SongFilter.DOWNLOADED, 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() .clickable { if (song.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( ListQueue( title = queueAllSongsStr, items = filteredSongs.map { it.toMediaItem() }, startIndex = index, ), ) } }.animateItem(), ) } } // Show upload FAB when on UPLOADED filter, shuffle FAB otherwise HideOnScrollFAB( visible = if (filter == SongFilter.UPLOADED) true else filteredSongs.isNotEmpty(), lazyListState = lazyListState, icon = if (filter == SongFilter.UPLOADED) R.drawable.upload else R.drawable.shuffle, onClick = { if (filter == SongFilter.UPLOADED) { filePickerLauncher.launch( arrayOf( "audio/mpeg", "audio/mp4", "audio/x-m4a", "audio/flac", "audio/ogg", "audio/x-ms-wma", ), ) } else { playerConnection.playQueue( ListQueue( title = queueAllSongsStr, items = filteredSongs.shuffled().map { it.toMediaItem() }, ), ) } }, ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/AutoPlaylistScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.playlist import android.net.Uri import android.widget.Toast 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.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.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.ime import androidx.compose.foundation.layout.only 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.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.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.mutableFloatStateOf 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.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow 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.util.fastForEachReversed import androidx.compose.ui.util.fastSumBy import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.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 coil3.compose.AsyncImage import com.metrolist.innertube.YouTube import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.SongSortDescendingKey import com.metrolist.music.constants.SongSortType import com.metrolist.music.constants.SongSortTypeKey import com.metrolist.music.constants.YtmSyncKey import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.DraggableScrollbar import com.metrolist.music.ui.component.EmptyPlaceholder import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.ui.menu.AutoPlaylistMenu import com.metrolist.music.ui.menu.SelectionSongMenu import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.ui.utils.isScrollingUp import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.AutoPlaylistViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun AutoPlaylistScreen( navController: NavController, viewModel: AutoPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val uploadUnsupportedFormatStr = stringResource(R.string.upload_unsupported_format) val uploadFileTooLargeStr = stringResource(R.string.upload_file_too_large) val uploadFailedStr = stringResource(R.string.upload_failed) val uploadCompleteStr = stringResource(R.string.upload_complete) val focusManager = LocalFocusManager.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val playlist = when (viewModel.playlist) { "liked" -> stringResource(R.string.liked) "uploaded" -> stringResource(R.string.uploaded_playlist) else -> stringResource(R.string.offline) } val songs by viewModel.likedSongs.collectAsState(null) val mutableSongs = remember { mutableStateListOf() } var isSearching by remember { mutableStateOf(false) } var query by remember { mutableStateOf(TextFieldValue()) } val focusRequester = remember { FocusRequester() } LaunchedEffect(isSearching) { if (isSearching) { focusRequester.requestFocus() } } val hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) 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 "uploaded" -> PlaylistType.UPLOADED else -> PlaylistType.OTHER } var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, String>( save = { it.toList() }, restore = { it.toMutableStateList() }, ), ) { mutableStateListOf() } var selectionAnchorSongId by rememberSaveable { mutableStateOf(null) } val onExitSelectionMode = { inSelectMode = false selection.clear() selectionAnchorSongId = null } if (isSearching) { BackHandler { isSearching = false query = TextFieldValue() } } else if (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } 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) } val scope = rememberCoroutineScope() // Upload state var showUploadDialog by remember { mutableStateOf(false) } var uploadProgress by remember { mutableFloatStateOf(0f) } var currentUploadIndex by remember { mutableIntStateOf(0) } var totalUploads by remember { mutableIntStateOf(0) } var currentFileName by remember { mutableStateOf("") } var isUploading by remember { mutableStateOf(false) } var uploadJob by remember { mutableStateOf(null) } val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenMultipleDocuments(), ) { uris: List -> if (uris.isNotEmpty()) { uploadJob = scope.launch { isUploading = true showUploadDialog = true totalUploads = uris.size var successCount = 0 uris.forEachIndexed { index, uri -> currentUploadIndex = index + 1 uploadProgress = 0f try { val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "unknown" currentFileName = fileName val extension = fileName.substringAfterLast('.', "").lowercase() if (extension !in YouTube.SUPPORTED_UPLOAD_TYPES) { withContext(Dispatchers.Main) { Toast .makeText( context, uploadUnsupportedFormatStr, Toast.LENGTH_SHORT, ).show() } return@forEachIndexed } val inputStream = context.contentResolver.openInputStream(uri) val data = inputStream?.readBytes() inputStream?.close() if (data == null) return@forEachIndexed if (data.size > YouTube.MAX_UPLOAD_SIZE) { withContext(Dispatchers.Main) { Toast .makeText( context, uploadFileTooLargeStr, Toast.LENGTH_SHORT, ).show() } return@forEachIndexed } val result = YouTube.uploadSong( filename = fileName, data = data, onProgress = { progress -> uploadProgress = progress }, ) if (result.isSuccess && result.getOrDefault(false)) { successCount++ } } catch (e: Exception) { withContext(Dispatchers.Main) { Toast .makeText( context, uploadFailedStr + ": ${e.message}", Toast.LENGTH_SHORT, ).show() } } } isUploading = false if (successCount > 0) { // Show completion briefly uploadProgress = 1f currentFileName = uploadCompleteStr kotlinx.coroutines.delay(1000) // Show toast on main thread withContext(Dispatchers.Main) { Toast .makeText( context, uploadCompleteStr, Toast.LENGTH_SHORT, ).show() } showUploadDialog = false // Refresh uploaded songs viewModel.syncUploadedSongs() } else { showUploadDialog = false } } } } LaunchedEffect(Unit) { println("[UPLOAD_DEBUG] AutoPlaylistScreen LaunchedEffect: playlistId=$playlistId, playlistType=$playlistType, ytmSync=$ytmSync") if (ytmSync) { withContext(Dispatchers.IO) { if (playlistType == PlaylistType.LIKE) { println("[UPLOAD_DEBUG] AutoPlaylistScreen: Calling syncLikedSongs()") viewModel.syncLikedSongs() } if (playlistType == PlaylistType.UPLOADED) { println("[UPLOAD_DEBUG] AutoPlaylistScreen: Calling syncUploadedSongs()") viewModel.syncUploadedSongs() } } } else { println("[UPLOAD_DEBUG] AutoPlaylistScreen: ytmSync is false, not syncing") } } 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)) } }, ) } // Upload progress dialog if (showUploadDialog) { DefaultDialog( onDismiss = { if (isUploading) { uploadJob?.cancel() isUploading = false } showUploadDialog = false }, icon = { Icon( painter = painterResource(R.drawable.upload), contentDescription = null, ) }, title = { Text(stringResource(R.string.uploading)) }, buttons = { TextButton( onClick = { if (isUploading) { uploadJob?.cancel() isUploading = false } showUploadDialog = false }, ) { Text(stringResource(R.string.cancel)) } }, ) { Text( text = stringResource(R.string.upload_progress, currentUploadIndex, totalUploads), style = MaterialTheme.typography.bodyMedium, ) Spacer(modifier = Modifier.height(8.dp)) Text( text = currentFileName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(16.dp)) LinearProgressIndicator( progress = { uploadProgress }, modifier = Modifier.fillMaxWidth(), ) } } val filteredSongs = remember(songs, query) { if (query.text.isEmpty()) { songs ?: emptyList() } else { songs?.filter { song -> song.song.title.contains(query.text, true) || song.artists.any { it.name.contains(query.text, true) } } ?: emptyList() } } LaunchedEffect(filteredSongs) { selection.fastForEachReversed { songId -> if (filteredSongs.find { it.id == songId } == null) { selection.remove(songId) } } if (selectionAnchorSongId != null && filteredSongs.none { it.id == selectionAnchorSongId }) { selectionAnchorSongId = filteredSongs.firstOrNull { it.id in selection }?.id } } val state = rememberLazyListState() val isRefreshing by viewModel.isRefreshing.collectAsState() val pullRefreshState = rememberPullToRefreshState() val canRefresh = playlistType == PlaylistType.LIKE || playlistType == PlaylistType.UPLOADED Box( modifier = Modifier .fillMaxSize() .then( if (canRefresh) { Modifier.pullToRefresh( state = pullRefreshState, isRefreshing = isRefreshing, onRefresh = viewModel::refresh, ) } else { Modifier }, ), ) { LazyColumn( state = state, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { if (songs != null) { if (songs!!.isEmpty()) { item(key = "empty_placeholder") { EmptyPlaceholder( icon = R.drawable.music_note, text = stringResource(R.string.playlist_is_empty), ) } } else { if (!isSearching) { item(key = "playlist_header") { AutoPlaylistHeader( name = playlist, songs = songs!!, likeLength = likeLength, downloadState = downloadState, onShowRemoveDownloadDialog = { showRemoveDownloadDialog = true }, menuState = menuState, modifier = Modifier.animateItem(), ) } } item(key = "songs_header") { 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), ) } } } if (filteredSongs.isNotEmpty()) { itemsIndexed( items = filteredSongs, key = { _, song -> song.id }, ) { index, song -> val onCheckedChange: (Boolean) -> Unit = { if (it) { selection.add(song.id) } else { selection.remove(song.id) } } SongListItem( song = song, isActive = song.song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, trailingContent = { if (inSelectMode) { Checkbox( checked = song.id in selection, onCheckedChange = onCheckedChange, ) } else { 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 (inSelectMode) { onCheckedChange(song.id !in selection) } else if (song.song.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( ListQueue( title = playlist, items = songs!!.map { it.toMediaItem() }, startIndex = songs!!.indexOfFirst { it.id == song.id }, ), ) } }, onLongClick = { if (!inSelectMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) selectionAnchorSongId = song.id } else { val anchorIndex = selectionAnchorSongId?.let { anchorSongId -> filteredSongs.indexOfFirst { it.id == anchorSongId } } ?: -1 if (anchorIndex == -1) { onCheckedChange(true) selectionAnchorSongId = song.id } else { val range = if (anchorIndex <= index) anchorIndex..index else index..anchorIndex for (rangeIndex in range) { val rangeSongId = filteredSongs[rangeIndex].id if (rangeSongId !in selection) { selection.add(rangeSongId) } } } } }, ).animateItem(), ) } } } } DraggableScrollbar( modifier = Modifier .padding( LocalPlayerAwareWindowInsets.current .union(WindowInsets.ime) .asPaddingValues(), ).align(Alignment.CenterEnd), scrollState = state, headerItems = 2, ) if (canRefresh) { Indicator( isRefreshing = isRefreshing, state = pullRefreshState, modifier = Modifier .align(Alignment.TopCenter) .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()), ) } // Upload FAB for uploaded playlist - positioned above mini player if (playlistType == PlaylistType.UPLOADED) { androidx.compose.animation.AnimatedVisibility( visible = state.isScrollingUp(), enter = androidx.compose.animation.slideInVertically { it }, exit = androidx.compose.animation.slideOutVertically { it }, modifier = Modifier .align(Alignment.BottomEnd) .windowInsetsPadding( LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal), ).padding(16.dp), ) { FloatingActionButton( onClick = { filePickerLauncher.launch( arrayOf( "audio/mpeg", "audio/mp4", "audio/x-m4a", "audio/flac", "audio/ogg", "audio/x-ms-wma", ), ) }, ) { Icon( painter = painterResource(R.drawable.upload), contentDescription = stringResource(R.string.upload_songs), ) } } } TopAppBar( title = { when { inSelectMode -> { Text( text = pluralStringResource(R.plurals.n_song, selection.size, selection.size), 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( text = playlist, style = MaterialTheme.typography.titleLarge, ) } } }, navigationIcon = { IconButton( onClick = { when { isSearching -> { isSearching = false query = TextFieldValue() focusManager.clearFocus() } inSelectMode -> { onExitSelectionMode() } else -> { navController.navigateUp() } } }, onLongClick = { if (!isSearching && !inSelectMode) { navController.backToMain() } }, ) { Icon( painter = painterResource( if (inSelectMode) R.drawable.close else R.drawable.arrow_back, ), contentDescription = null, ) } }, actions = { if (inSelectMode) { Checkbox( checked = selection.size == filteredSongs.size && selection.isNotEmpty(), onCheckedChange = { if (selection.size == filteredSongs.size) { selection.clear() } else { selection.clear() selection.addAll(filteredSongs.map { it.id }) } }, ) IconButton( enabled = selection.isNotEmpty(), onClick = { menuState.show { SelectionSongMenu( songSelection = filteredSongs.filter { it.id in selection }, onDismiss = menuState::dismiss, clearAction = onExitSelectionMode, isUploadedPlaylist = playlistType == PlaylistType.UPLOADED, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, ) } } else if (!isSearching) { IconButton( onClick = { isSearching = true }, ) { Icon( painter = painterResource(R.drawable.search), contentDescription = null, ) } } }, ) } } @Composable private fun AutoPlaylistHeader( name: String, songs: List, likeLength: Int, downloadState: Int, onShowRemoveDownloadDialog: () -> Unit, menuState: com.metrolist.music.ui.component.MenuState, modifier: Modifier = Modifier, ) { val playerConnection = LocalPlayerConnection.current ?: return val context = LocalContext.current Column( modifier = modifier .fillMaxWidth() .padding(top = 8.dp, bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { // Playlist Thumbnail - Large centered with shadow Box( modifier = Modifier.padding(top = 8.dp, bottom = 20.dp), ) { androidx.compose.material3.Surface( modifier = Modifier .size(240.dp) .shadow( elevation = 24.dp, shape = RoundedCornerShape(3.dp), spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), ), shape = RoundedCornerShape(3.dp), ) { AsyncImage( model = songs[0].song.thumbnailUrl, contentDescription = null, contentScale = androidx.compose.ui.layout.ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) } } // Playlist Name Text( text = name, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = androidx.compose.ui.text.style.TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 32.dp), ) Spacer(modifier = Modifier.height(12.dp)) // Metadata - Song Count • Duration Text( text = buildString { append(pluralStringResource(R.plurals.n_song, songs.size, songs.size)) if (likeLength > 0) { append(" • ") append(makeTimeString(likeLength * 1000L)) } }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), ) Spacer(modifier = Modifier.height(24.dp)) // Action Buttons Row Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { // Shuffle Button - Smaller secondary button androidx.compose.material3.Surface( onClick = { playerConnection.playQueue( ListQueue( title = name, items = songs.shuffled().map { it.toMediaItem() }, ), ) }, shape = androidx.compose.foundation.shape.CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp), ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = stringResource(R.string.shuffle), modifier = Modifier.size(24.dp), ) } } // Play Button - Larger primary circular button Surface( onClick = { playerConnection.playQueue( ListQueue( title = name, items = songs.map { it.toMediaItem() }, ), ) }, color = MaterialTheme.colorScheme.primary, shape = androidx.compose.foundation.shape.CircleShape, modifier = Modifier.size(72.dp), ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Icon( painter = painterResource(R.drawable.play), contentDescription = stringResource(R.string.play), tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(32.dp), ) } } // Menu Button - Smaller secondary button Surface( onClick = { menuState.show { AutoPlaylistMenu( downloadState = downloadState, onQueue = { playerConnection.addToQueue( songs.map { it.toMediaItem() }, ) }, onDownload = { when (downloadState) { Download.STATE_COMPLETED -> { onShowRemoveDownloadDialog() } Download.STATE_DOWNLOADING -> { songs.forEach { song -> DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.song.id, false, ) } } else -> { 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, ) } } } }, onDismiss = { menuState.dismiss() }, songs = songs, playlistName = name, ) } }, shape = androidx.compose.foundation.shape.CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp), ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, modifier = Modifier.size(24.dp), ) } } } } } enum class PlaylistType { LIKE, DOWNLOAD, UPLOADED, OTHER, } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/CachePlaylistScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.playlist import androidx.activity.compose.BackHandler 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.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.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.Checkbox 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.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.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow 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.util.fastForEachReversed import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.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 coil3.compose.AsyncImage import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.SongSortDescendingKey import com.metrolist.music.constants.SongSortType import com.metrolist.music.constants.SongSortTypeKey import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.DraggableScrollbar import com.metrolist.music.ui.component.EmptyPlaceholder import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.ui.menu.CachePlaylistMenu import com.metrolist.music.ui.menu.SelectionSongMenu import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.CachePlaylistViewModel import java.time.LocalDateTime @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun CachePlaylistScreen( navController: NavController, viewModel: CachePlaylistViewModel = 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.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val cachedSongs by viewModel.cachedSongs.collectAsState() val (sortType, onSortTypeChange) = rememberEnumPreference( SongSortTypeKey, SongSortType.CREATE_DATE ) val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) val hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) val sortedSongs = remember(cachedSongs, sortType, sortDescending) { val sorted = when (sortType) { SongSortType.CREATE_DATE -> cachedSongs.sortedBy { it.song.dateDownload ?: LocalDateTime.MIN } SongSortType.NAME -> cachedSongs.sortedBy { it.song.title } SongSortType.ARTIST -> cachedSongs.sortedBy { song -> song.artists.joinToString(separator = "") { it.name } } SongSortType.PLAY_TIME -> cachedSongs.sortedBy { it.song.totalPlayTime } } if (sortDescending) sorted.reversed() else sorted } var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, String>( save = { it.toList() }, restore = { it.toMutableStateList() } ) ) { mutableStateListOf() } var selectionAnchorSongId by rememberSaveable { mutableStateOf(null) } val onExitSelectionMode = { inSelectMode = false selection.clear() selectionAnchorSongId = null } 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() } } if (isSearching) { BackHandler { isSearching = false query = TextFieldValue() } } else if (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } val filteredSongs = remember(sortedSongs, query) { if (query.text.isEmpty()) sortedSongs else sortedSongs.filter { song -> song.title.contains(query.text, true) || song.artists.any { it.name.contains(query.text, true) } } } LaunchedEffect(filteredSongs) { selection.fastForEachReversed { songId -> if (filteredSongs.find { it.id == songId } == null) { selection.remove(songId) } } if (selectionAnchorSongId != null && filteredSongs.none { it.id == selectionAnchorSongId }) { selectionAnchorSongId = filteredSongs.firstOrNull { it.id in selection }?.id } } Box( modifier = Modifier.fillMaxSize(), ) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { if (filteredSongs.isEmpty() && !isSearching) { item(key = "empty_placeholder") { EmptyPlaceholder( icon = R.drawable.music_note, text = stringResource(R.string.playlist_is_empty), modifier = Modifier.animateItem() ) } } if (filteredSongs.isEmpty() && isSearching) { item(key = "no_results") { EmptyPlaceholder( icon = R.drawable.search, text = stringResource(R.string.no_results_found), modifier = Modifier.animateItem() ) } } else { if (filteredSongs.isNotEmpty() && !isSearching) { item(key = "playlist_header") { CachePlaylistHeader( songs = filteredSongs, context = context, menuState = menuState, modifier = Modifier.animateItem() ) } } if (filteredSongs.isNotEmpty()) { item(key = "sort_header") { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(start = 16.dp) .animateItem(), ) { 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), ) } } } itemsIndexed(filteredSongs, key = { _, song -> song.id }) { index, song -> val onCheckedChange: (Boolean) -> Unit = { if (it) { selection.add(song.id) } else { selection.remove(song.id) } } SongListItem( song = song, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, trailingContent = { if (inSelectMode) { Checkbox( checked = song.id in selection, onCheckedChange = onCheckedChange ) } else { IconButton(onClick = { menuState.show { SongMenu( originalSong = song, navController = navController, onDismiss = menuState::dismiss, isFromCache = true, ) } }) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null ) } } }, modifier = Modifier .fillMaxWidth() .animateItem() .combinedClickable( onClick = { if (inSelectMode) { onCheckedChange(song.id !in selection) } else if (song.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( ListQueue( title = "Cache Songs", items = cachedSongs.map { it.toMediaItem() }, startIndex = cachedSongs.indexOfFirst { it.id == song.id } ) ) } }, onLongClick = { if (!inSelectMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) selectionAnchorSongId = song.id } else { val anchorIndex = selectionAnchorSongId?.let { anchorSongId -> filteredSongs.indexOfFirst { it.id == anchorSongId } } ?: -1 if (anchorIndex == -1) { onCheckedChange(true) selectionAnchorSongId = song.id } else { val range = if (anchorIndex <= index) anchorIndex..index else index..anchorIndex for (rangeIndex in range) { val rangeSongId = filteredSongs[rangeIndex].id if (rangeSongId !in selection) { selection.add(rangeSongId) } } } } } ) .animateItem() ) } } } DraggableScrollbar( modifier = Modifier .padding( LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime) .asPaddingValues() ) .align(Alignment.CenterEnd), scrollState = lazyListState, headerItems = 2 ) TopAppBar( title = { when { inSelectMode -> { Text( text = pluralStringResource(R.plurals.n_song, selection.size, selection.size), 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() } inSelectMode -> { onExitSelectionMode() } else -> { navController.navigateUp() } } }, onLongClick = { if (!isSearching && !inSelectMode) { navController.backToMain() } }) { Icon( painter = painterResource( if (inSelectMode) R.drawable.close else R.drawable.arrow_back ), contentDescription = null ) } }, actions = { if (inSelectMode) { Checkbox( checked = selection.size == filteredSongs.size && selection.isNotEmpty(), onCheckedChange = { if (selection.size == filteredSongs.size) { selection.clear() } else { selection.clear() selection.addAll(filteredSongs.map { it.id }) } } ) IconButton( enabled = selection.isNotEmpty(), onClick = { menuState.show { SelectionSongMenu( songSelection = filteredSongs.filter { it.id in selection }, onDismiss = menuState::dismiss, clearAction = onExitSelectionMode ) } } ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null ) } } else if (!isSearching) { IconButton(onClick = { isSearching = true }) { Icon( painter = painterResource(R.drawable.search), contentDescription = null ) } } } ) } } @Composable private fun CachePlaylistHeader( songs: List, context: android.content.Context, menuState: com.metrolist.music.ui.component.MenuState, modifier: Modifier = Modifier ) { val playerConnection = LocalPlayerConnection.current ?: return Column( modifier = modifier .fillMaxWidth() .padding(top = 8.dp, bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // Playlist Thumbnail - Large centered with shadow Box( modifier = Modifier.padding(top = 8.dp, bottom = 20.dp) ) { androidx.compose.material3.Surface( modifier = Modifier .size(240.dp) .shadow( elevation = 24.dp, shape = RoundedCornerShape(3.dp), spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) ), shape = RoundedCornerShape(3.dp) ) { AsyncImage( model = songs.first().thumbnailUrl, contentDescription = null, contentScale = androidx.compose.ui.layout.ContentScale.Crop, modifier = Modifier.fillMaxSize() ) } } // Playlist Name Text( text = stringResource(R.string.cached_playlist), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = androidx.compose.ui.text.style.TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 32.dp) ) Spacer(modifier = Modifier.height(12.dp)) // Metadata - Song Count Text( text = pluralStringResource(R.plurals.n_song, songs.size, songs.size), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) ) Spacer(modifier = Modifier.height(24.dp)) // Action Buttons Row Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically ) { // Shuffle Button - Smaller secondary button androidx.compose.material3.Surface( onClick = { playerConnection.playQueue( ListQueue( title = "Cache Songs", items = songs.shuffled().map { it.toMediaItem() }, ) ) }, shape = androidx.compose.foundation.shape.CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = stringResource(R.string.shuffle), modifier = Modifier.size(24.dp) ) } } // Play Button - Larger primary circular button Surface( onClick = { playerConnection.playQueue( ListQueue( title = "Cache Songs", items = songs.map { it.toMediaItem() }, ) ) }, color = MaterialTheme.colorScheme.primary, shape = androidx.compose.foundation.shape.CircleShape, modifier = Modifier.size(72.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Icon( painter = painterResource(R.drawable.play), contentDescription = stringResource(R.string.play), tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(32.dp) ) } } // Menu Button - Smaller secondary button Surface( onClick = { menuState.show { CachePlaylistMenu( downloadState = Download.STATE_STOPPED, onQueue = { playerConnection.addToQueue( songs.map { it.toMediaItem() } ) }, onDownload = { // Download all cached songs 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, ) } }, onDismiss = { menuState.dismiss() } ) } }, shape = androidx.compose.foundation.shape.CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, modifier = Modifier.size(24.dp) ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/LocalPlaylistScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.playlist import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable 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.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.windowInsetsPadding 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.KeyboardOptions import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface 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.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.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb 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.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.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.util.fastForEachReversed import androidx.compose.ui.util.fastSumBy import androidx.core.content.FileProvider import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.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 coil3.compose.AsyncImage import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.utils.completed import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.LocalSyncUtils import com.metrolist.music.R import com.metrolist.music.constants.DarkModeKey import com.metrolist.music.constants.PlaylistEditLockKey import com.metrolist.music.constants.PlaylistSongSortDescendingKey import com.metrolist.music.constants.PlaylistSongSortType import com.metrolist.music.constants.PlaylistSongSortTypeKey import com.metrolist.music.constants.SwipeToRemoveSongKey import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.PlaylistSong import com.metrolist.music.db.entities.PlaylistSongMap import com.metrolist.music.extensions.move import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.ActionPromptDialog import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.DraggableScrollbar import com.metrolist.music.ui.component.EmptyPlaceholder import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.OverlayEditButton import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.ui.component.TextFieldDialog import com.metrolist.music.ui.menu.CustomThumbnailMenu import com.metrolist.music.ui.menu.LocalPlaylistMenu import com.metrolist.music.ui.menu.SelectionSongMenu import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.screens.settings.DarkMode import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import com.metrolist.music.utils.reportException import com.metrolist.music.viewmodels.LocalPlaylistViewModel import com.yalantis.ucrop.UCrop import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext 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, 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.isEffectivelyPlaying.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() } } var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, Int>( save = { it.toList() }, restore = { it.toMutableStateList() }, ), ) { mutableStateListOf() } var selectionAnchorMapId by rememberSaveable { mutableStateOf(null) } val onExitSelectionMode = { inSelectMode = false selection.clear() selectionAnchorMapId = null } if (isSearching) { BackHandler { isSearching = false query = TextFieldValue() } } else if (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } val downloadUtil = LocalDownloadUtil.current var downloadState by remember { mutableIntStateOf(Download.STATE_STOPPED) } val editable: Boolean = playlist?.playlist?.isEditable == true LaunchedEffect(songs) { selection.fastForEachReversed { mapId -> if (songs.find { it.map.id == mapId } == null) { selection.remove(Integer.valueOf(mapId)) } } if (selectionAnchorMapId != null && songs.none { it.map.id == selectionAnchorMapId }) { selectionAnchorMapId = songs.firstOrNull { it.map.id in selection }?.map?.id } } 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() var dragInfo by remember { mutableStateOf?>(null) } val reorderableState = rememberReorderableLazyListState( lazyListState = lazyListState, scrollThresholdPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues(), ) { from, to -> if (to.index >= headerItems && from.index >= headerItems) { val currentDragInfo = dragInfo dragInfo = if (currentDragInfo == null) { (from.index - headerItems) to (to.index - headerItems) } else { currentDragInfo.first to (to.index - headerItems) } mutableSongs.move(from.index - headerItems, to.index - headerItems) } } LaunchedEffect(reorderableState.isAnyItemDragging) { if (!reorderableState.isAnyItemDragging) { dragInfo?.let { (from, to) -> database.transaction { move(viewModel.playlistId, from, to) } // Sync order with YT Music if (viewModel.playlist.value ?.playlist ?.browseId != null ) { viewModel.viewModelScope.launch(Dispatchers.IO) { val playlistSongMap = database.playlistSongMaps(viewModel.playlistId, 0) val successorIndex = if (from > to) to else to + 1 val successorSetVideoId = playlistSongMap.getOrNull(successorIndex)?.setVideoId playlistSongMap.getOrNull(from)?.setVideoId?.let { setVideoId -> YouTube.moveSongPlaylist( viewModel.playlist.value ?.playlist ?.browseId!!, setVideoId, successorSetVideoId, ) } } } dragInfo = null } } } val showTopBarTitle by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } } Box( modifier = Modifier.fillMaxSize(), ) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime).asPaddingValues(), ) { playlist?.let { playlist -> if (playlist.songCount == 0 && playlist.playlist.remoteSongCount == 0) { item(key = "empty_placeholder") { EmptyPlaceholder( icon = R.drawable.music_note, text = stringResource(R.string.playlist_is_empty), modifier = Modifier.animateItem(), ) } } else { if (!isSearching) { item(key = "playlist_header") { LocalPlaylistHeader( playlist = playlist, songs = songs, onShowEditDialog = { showEditDialog = true }, onShowRemoveDownloadDialog = { showRemoveDownloadDialog = true }, onshowDeletePlaylistDialog = { showDeletePlaylistDialog = true }, onStartSearch = { isSearching = true }, snackbarHostState = snackbarHostState, modifier = Modifier.animateItem(), ) } } item(key = "controls_row") { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(start = 16.dp) .animateItem(), ) { 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, ) } } } } } } val displayedSongs = if (isSearching) filteredSongs else mutableSongs itemsIndexed( items = displayedSongs, key = { _, song -> song.map.id }, ) { index, song -> ReorderableItem( state = reorderableState, key = song.map.id, ) { val currentItem by rememberUpdatedState(song) fun deleteFromPlaylist() { database.transaction { coroutineScope.launch { playlist?.playlist?.browseId?.let { browseId -> val setVideoId = getSetVideoId(currentItem.map.songId) setVideoId?.setVideoId?.let { setVideoIdValue -> YouTube.removeFromPlaylist( browseId, currentItem.map.songId, setVideoIdValue, ) } } } move( currentItem.map.playlistId, currentItem.map.position, Int.MAX_VALUE, ) delete(currentItem.map.copy(position = Int.MAX_VALUE)) } } val swipeRemoveEnabled by rememberPreference(SwipeToRemoveSongKey, defaultValue = false) val dismissBoxState = rememberSwipeToDismissBoxState( positionalThreshold = { totalDistance -> totalDistance }, ) var processedDismiss by remember { mutableStateOf(false) } LaunchedEffect(dismissBoxState.currentValue) { val dv = dismissBoxState.currentValue if (swipeRemoveEnabled && !processedDismiss && ( dv == SwipeToDismissBoxValue.StartToEnd || dv == SwipeToDismissBoxValue.EndToStart ) ) { processedDismiss = true deleteFromPlaylist() } if (dv == SwipeToDismissBoxValue.Settled) { processedDismiss = false } } val onCheckedChange: (Boolean) -> Unit = { if (it) { selection.add(song.map.id) } else { selection.remove(Integer.valueOf(song.map.id)) } } val content: @Composable () -> Unit = { SongListItem( song = song.song, isActive = song.song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, trailingContent = { if (inSelectMode) { Checkbox( checked = selection.contains(song.map.id), onCheckedChange = onCheckedChange, ) } else { 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 && !inSelectMode && !isSearching && editable) { IconButton( onClick = { }, modifier = Modifier.draggableHandle(), ) { Icon( painter = painterResource(R.drawable.drag_handle), contentDescription = null, ) } } } }, modifier = Modifier .fillMaxWidth() .combinedClickable( onClick = { if (inSelectMode) { onCheckedChange(!selection.contains(song.map.id)) } else if (song.song.id == mediaMetadata?.id) { playerConnection.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 = { if (!inSelectMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) selectionAnchorMapId = song.map.id } else { val anchorIndex = selectionAnchorMapId?.let { anchorMapId -> displayedSongs.indexOfFirst { it.map.id == anchorMapId } } ?: -1 if (anchorIndex == -1) { onCheckedChange(true) selectionAnchorMapId = song.map.id } else { val range = if (anchorIndex <= index) anchorIndex..index else index..anchorIndex for (rangeIndex in range) { val rangeMapId = displayedSongs[rangeIndex].map.id if (rangeMapId !in selection) { selection.add(rangeMapId) } } } } }, ), ) } if (locked || inSelectMode || !swipeRemoveEnabled) { Box(modifier = Modifier.animateItem()) { content() } } else { SwipeToDismissBox( state = dismissBoxState, backgroundContent = {}, modifier = Modifier.animateItem(), ) { content() } } } } } DraggableScrollbar( modifier = Modifier .padding( LocalPlayerAwareWindowInsets.current .union(WindowInsets.ime) .asPaddingValues(), ).align(Alignment.CenterEnd), scrollState = lazyListState, headerItems = 2, ) TopAppBar( title = { if (inSelectMode) { Text(pluralStringResource(R.plurals.n_selected, selection.size, selection.size)) } 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 = { if (inSelectMode) { IconButton(onClick = onExitSelectionMode) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, ) } } else { IconButton( onClick = { if (isSearching) { isSearching = false query = TextFieldValue() } else { navController.navigateUp() } }, onLongClick = { if (!isSearching) { navController.backToMain() } }, ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = null, ) } } }, actions = { if (inSelectMode) { Checkbox( checked = selection.size == songs.size && selection.isNotEmpty(), onCheckedChange = { if (selection.size == songs.size) { selection.clear() } else { selection.clear() selection.addAll(songs.map { it.map.id }) } }, ) IconButton( enabled = selection.isNotEmpty(), onClick = { menuState.show { SelectionSongMenu( songSelection = selection.mapNotNull { mapId -> songs.find { it.map.id == mapId }?.song }, songPosition = selection.mapNotNull { mapId -> songs.find { it.map.id == mapId }?.map }, onDismiss = menuState::dismiss, clearAction = onExitSelectionMode, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, ) } } else if (!isSearching) { // Only search button remains in TopAppBar 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, onStartSearch: () -> Unit, snackbarHostState: SnackbarHostState, modifier: Modifier, ) { val playerConnection = LocalPlayerConnection.current ?: return val context = LocalContext.current val database = LocalDatabase.current val menuState = LocalMenuState.current val syncUtils = LocalSyncUtils.current val scope = rememberCoroutineScope() val editPlaylistCoverStr = stringResource(R.string.edit_playlist_cover) val playlistSyncedStr = stringResource(R.string.playlist_synced) 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 val overrideThumbnail = remember { mutableStateOf(null) } var isCustomThumbnail: Boolean = playlist.thumbnails.firstOrNull()?.let { it.contains("studio_square_thumbnail") || it.contains("content://com.metrolist.music") } ?: false val result = remember { mutableStateOf(null) } var pendingCropDestUri by remember { mutableStateOf(null) } var showEditNoteDialog by remember { mutableStateOf(false) } val cropLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { res -> if (res.resultCode == android.app.Activity.RESULT_OK) { val output = res.data?.let { UCrop.getOutput(it) } ?: pendingCropDestUri if (output != null) result.value = output } } val (darkMode, _) = rememberEnumPreference( DarkModeKey, defaultValue = DarkMode.AUTO, ) val cropColor = MaterialTheme.colorScheme val darkTheme = darkMode == DarkMode.ON || (darkMode == DarkMode.AUTO && isSystemInDarkTheme()) val pickLauncher = rememberLauncherForActivityResult( ActivityResultContracts.PickVisualMedia(), ) { uri -> uri?.let { sourceUri -> val destFile = java.io.File(context.cacheDir, "playlist_cover_crop_${System.currentTimeMillis()}.jpg") val destUri = FileProvider.getUriForFile(context, "${context.packageName}.FileProvider", destFile) pendingCropDestUri = destUri val options = UCrop.Options().apply { setCompressionFormat(Bitmap.CompressFormat.JPEG) setCompressionQuality(90) setHideBottomControls(true) setToolbarTitle(editPlaylistCoverStr) setStatusBarLight(!darkTheme) setToolbarColor(cropColor.surface.toArgb()) setToolbarWidgetColor(cropColor.inverseSurface.toArgb()) setRootViewBackgroundColor(cropColor.surface.toArgb()) setLogoColor(cropColor.surface.toArgb()) } val intent = UCrop .of(sourceUri, destUri) .withAspectRatio(1f, 1f) .withOptions(options) .getIntent(context) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) cropLauncher.launch(intent) } } LaunchedEffect(result.value) { val uri = result.value ?: return@LaunchedEffect withContext(Dispatchers.IO) { when { playlist.playlist.browseId == null -> { overrideThumbnail.value = uri.toString() isCustomThumbnail = true // Update the database with the new thumbnail database.query { update(playlist.playlist.copy(thumbnailUrl = uri.toString())) } } else -> { val bytes = uriToByteArray(context, uri) YouTube .uploadCustomThumbnailLink( playlist.playlist.browseId, bytes!!, ).onSuccess { newThumbnailUrl -> overrideThumbnail.value = newThumbnailUrl isCustomThumbnail = true // Update the database with the new thumbnail URL database.query { update(playlist.playlist.copy(thumbnailUrl = newThumbnailUrl)) } }.onFailure { if (it is ClientRequestException) { snackbarHostState.showSnackbar("${it.response.status.value} ${it.response.status.description}") } reportException(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( modifier = modifier .fillMaxWidth() .padding(top = 8.dp, bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { if (showEditNoteDialog) { ActionPromptDialog( title = stringResource(R.string.edit_playlist_cover), onDismiss = { showEditNoteDialog = false }, onConfirm = { showEditNoteDialog = false pickLauncher.launch( PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly), ) }, onCancel = { showEditNoteDialog = false }, ) { if (playlist.playlist.browseId != null) { Text( text = stringResource(R.string.edit_playlist_cover_note), style = MaterialTheme.typography.bodyMedium, ) Spacer(Modifier.height(8.dp)) } Text( text = stringResource(R.string.edit_playlist_cover_note_wait), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), ) } } // Playlist Thumbnail(s) - Large centered with shadow Box( modifier = Modifier.padding(top = 8.dp, bottom = 20.dp), ) { when (playlist.thumbnails.size) { 0 -> { Surface( modifier = Modifier .size(240.dp) .shadow( elevation = 16.dp, shape = RoundedCornerShape(3.dp), ), shape = RoundedCornerShape(3.dp), color = MaterialTheme.colorScheme.surfaceVariant, ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.queue_music), contentDescription = null, modifier = Modifier.size(80.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } 1 -> { Surface( modifier = Modifier .size(240.dp) .shadow( elevation = 24.dp, shape = RoundedCornerShape(3.dp), spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), ), shape = RoundedCornerShape(3.dp), ) { AsyncImage( model = overrideThumbnail.value ?: playlist.thumbnails[0], contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) } if (editable) { OverlayEditButton( visible = true, alignment = Alignment.BottomEnd, onClick = { if (isCustomThumbnail) { menuState.show( { CustomThumbnailMenu( onEdit = { pickLauncher.launch( PickVisualMediaRequest( mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly, ), ) }, onRemove = { when { playlist.playlist.browseId == null -> { overrideThumbnail.value = null database.query { update(playlist.playlist.copy(thumbnailUrl = null)) } } else -> { scope.launch(Dispatchers.IO) { YouTube.removeThumbnailPlaylist(playlist.playlist.browseId).onSuccess { newThumbnailUrl -> overrideThumbnail.value = newThumbnailUrl database.query { update(playlist.playlist.copy(thumbnailUrl = newThumbnailUrl)) } } } } } isCustomThumbnail = false }, onDismiss = menuState::dismiss, ) }, ) } else { showEditNoteDialog = true } }, ) } } else -> { Surface( modifier = Modifier .size(240.dp) .shadow( elevation = 24.dp, shape = RoundedCornerShape(3.dp), spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), ), shape = RoundedCornerShape(3.dp), ) { Box(modifier = Modifier.fillMaxSize()) { 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(120.dp), ) } } } if (editable) { OverlayEditButton( visible = true, alignment = Alignment.BottomEnd, onClick = { if (isCustomThumbnail) { menuState.show( { CustomThumbnailMenu( onEdit = { pickLauncher.launch( PickVisualMediaRequest( mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly, ), ) }, onRemove = { when { playlist.playlist.browseId == null -> { overrideThumbnail.value = null database.query { update(playlist.playlist.copy(thumbnailUrl = null)) } } else -> { scope.launch(Dispatchers.IO) { YouTube.removeThumbnailPlaylist(playlist.playlist.browseId).onSuccess { newThumbnailUrl -> overrideThumbnail.value = newThumbnailUrl database.query { update(playlist.playlist.copy(thumbnailUrl = newThumbnailUrl)) } } } } } isCustomThumbnail = false }, onDismiss = menuState::dismiss, ) }, ) } else { showEditNoteDialog = true } }, ) } } } } // Playlist Name Text( text = playlist.playlist.name, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 32.dp), ) Spacer(modifier = Modifier.height(12.dp)) // Metadata - Song Count • Duration val songCount = if (playlist.songCount == 0 && playlist.playlist.remoteSongCount != null) { playlist.playlist.remoteSongCount } else { playlist.songCount } Text( text = buildString { append(pluralStringResource(R.plurals.n_song, songCount, songCount)) if (playlistLength > 0) { append(" • ") append(makeTimeString(playlistLength * 1000L)) } }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), ) Spacer(modifier = Modifier.height(24.dp)) // Action Buttons Row Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { // Shuffle Button - Smaller secondary button Surface( onClick = { playerConnection.playQueue( ListQueue( title = playlist.playlist.name, items = songs.shuffled().map { it.song.toMediaItem() }, ), ) }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp), ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = stringResource(R.string.shuffle), modifier = Modifier.size(24.dp), ) } } // Play Button - Larger primary circular button Surface( onClick = { playerConnection.playQueue( ListQueue( title = playlist.playlist.name, items = songs.map { it.song.toMediaItem() }, ), ) }, color = MaterialTheme.colorScheme.primary, shape = CircleShape, modifier = Modifier.size(72.dp), ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Icon( painter = painterResource(R.drawable.play), contentDescription = stringResource(R.string.play), tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(32.dp), ) } } // Menu Button - Smaller secondary button Surface( onClick = { menuState.show { LocalPlaylistMenu( playlist = playlist, songs = songs, context = context, downloadState = downloadState, onEdit = onShowEditDialog, onSync = { scope.launch(Dispatchers.IO) { val playlistPage = YouTube .playlist(playlist.playlist.browseId!!) .completed() .getOrNull() ?: return@launch database.transaction { clearPlaylist(playlist.id) playlistPage.songs .map(SongItem::toMediaMetadata) .onEach(::insert) .mapIndexed { position, song -> PlaylistSongMap( songId = song.id, playlistId = playlist.id, position = position, setVideoId = song.setVideoId, ) }.forEach(::insert) } } scope.launch(Dispatchers.Main) { snackbarHostState.showSnackbar(playlistSyncedStr) } }, onDelete = onshowDeletePlaylistDialog, onDownload = { when (downloadState) { Download.STATE_COMPLETED -> { onShowRemoveDownloadDialog() } Download.STATE_DOWNLOADING -> { songs.forEach { song -> DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.song.id, false, ) } } else -> { 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, ) } } } }, onQueue = { playerConnection.addToQueue( items = songs.map { it.song.toMediaItem() }, ) }, onDismiss = { menuState.dismiss() }, ) } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp), ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, modifier = Modifier.size(24.dp), ) } } } } } @Composable private fun MetadataChip( icon: Int, text: String, modifier: Modifier = Modifier, ) { Surface( modifier = modifier, shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( painter = painterResource(icon), contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = text, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) } } } fun uriToByteArray( context: Context, uri: Uri, ): ByteArray? = try { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } } catch (_: SecurityException) { null } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/OnlinePlaylistScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.playlist import androidx.activity.compose.BackHandler 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.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.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.KeyboardOptions import androidx.compose.material3.Checkbox import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface 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.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow 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.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.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEachReversed import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import coil3.compose.AsyncImage import coil3.request.ImageRequest import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.SongItem import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalListenTogetherManager import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.LocalSyncUtils import com.metrolist.music.R import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.PlaylistSongMap import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.YouTubePlaylistQueue import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.menu.YouTubePlaylistMenu import com.metrolist.music.ui.menu.YouTubeSelectionSongMenu import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.OnlinePlaylistViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun OnlinePlaylistScreen( navController: NavController, viewModel: OnlinePlaylistViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val database = LocalDatabase.current val haptic = LocalHapticFeedback.current val playerConnection = LocalPlayerConnection.current ?: return val listenTogetherManager = LocalListenTogetherManager.current val isListenTogetherGuest = listenTogetherManager?.let { it.isInRoom && !it.isHost } ?: false val coroutineScope = rememberCoroutineScope() val isPlaying by playerConnection.isEffectivelyPlaying.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() val isLoading by viewModel.isLoading.collectAsState() val isLoadingMore by viewModel.isLoadingMore.collectAsState() val error by viewModel.error.collectAsState() val isPodcastPlaylist = viewModel.isPodcastPlaylist val hideExplicit by rememberPreference(key = HideExplicitKey, defaultValue = false) val lazyListState = rememberLazyListState() 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 { i, s -> i to s } } else { songs.mapIndexed { i, s -> i to s }.filter { it.second.title.contains(query.text, true) || it.second.artists.fastAny { a -> a.name.contains(query.text, true) } } } } var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, String>( save = { it.toList() }, restore = { it.toMutableStateList() }, ), ) { mutableStateListOf() } var selectionAnchorSongId by rememberSaveable { mutableStateOf(null) } val onExitSelectionMode = { inSelectMode = false selection.clear() selectionAnchorSongId = null } val focusRequester = remember { FocusRequester() } LaunchedEffect(isSearching) { if (isSearching) focusRequester.requestFocus() } LaunchedEffect(filteredSongs) { selection.fastForEachReversed { songId -> if (filteredSongs.find { it.second.id == songId } == null) { selection.remove(songId) } } if (selectionAnchorSongId != null && filteredSongs.none { it.second.id == selectionAnchorSongId }) { selectionAnchorSongId = filteredSongs.firstOrNull { it.second.id in selection }?.second?.id } } if (isSearching) { BackHandler { isSearching = false query = TextFieldValue() } } else if (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } Box(Modifier.fillMaxSize()) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime).asPaddingValues(), ) { if (playlist == null || songs.isEmpty()) { if (isLoading) { item(key = "loading_placeholder") { Box( modifier = Modifier .fillParentMaxSize() .padding(32.dp), contentAlignment = Alignment.Center, ) { ContainedLoadingIndicator() } } } else if (error != null) { item(key = "error_placeholder") { Column( modifier = Modifier .fillParentMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( text = error ?: stringResource(R.string.error_unknown), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, ) Spacer(modifier = Modifier.height(16.dp)) androidx.compose.material3.TextButton(onClick = { viewModel.retry() }) { Text(stringResource(R.string.retry)) } } } } else if (!isLoading && songs.isEmpty()) { item(key = "empty_placeholder") { Box( modifier = Modifier .fillParentMaxSize() .padding(32.dp), contentAlignment = Alignment.Center, ) { Text( text = stringResource(R.string.playlist_is_empty), style = MaterialTheme.typography.bodyLarge, ) } } } } else { playlist?.let { playlist -> if (!isSearching) { item(key = "playlist_header") { OnlinePlaylistHeader( playlist = playlist, songs = songs, dbPlaylist = dbPlaylist, navController = navController, coroutineScope = coroutineScope, continuation = viewModel.continuation, isPodcastPlaylist = isPodcastPlaylist, modifier = Modifier.animateItem(), ) } } itemsIndexed(filteredSongs) { index, (_, songItem) -> val onCheckedChange: (Boolean) -> Unit = { if (it) { selection.add(songItem.id) } else { selection.remove(songItem.id) } } YouTubeListItem( item = songItem, isActive = mediaMetadata?.id == songItem.id, isPlaying = isPlaying, isSelected = inSelectMode && songItem.id in selection, modifier = Modifier .combinedClickable( enabled = !hideExplicit || !songItem.explicit, onClick = { if (inSelectMode) { onCheckedChange(songItem.id !in selection) } else if (songItem.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubePlaylistQueue( playlistId = playlist.id, playlistTitle = playlist.title, initialSongs = filteredSongs.map { it.second }, initialContinuation = viewModel.continuation, startIndex = index, ), ) } }, onLongClick = { if (!inSelectMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) selectionAnchorSongId = songItem.id } else { val anchorIndex = selectionAnchorSongId?.let { anchorSongId -> filteredSongs.indexOfFirst { it.second.id == anchorSongId } } ?: -1 if (anchorIndex == -1) { onCheckedChange(true) selectionAnchorSongId = songItem.id } else { val range = if (anchorIndex <= index) anchorIndex..index else index..anchorIndex for (rangeIndex in range) { val rangeSongId = filteredSongs[rangeIndex].second.id if (rangeSongId !in selection) { selection.add(rangeSongId) } } } } }, ).animateItem(), trailingContent = { if (inSelectMode) { Checkbox( checked = songItem.id in selection, onCheckedChange = onCheckedChange, ) } else { IconButton(onClick = { menuState.show { YouTubeSongMenu(songItem, navController, menuState::dismiss) } }) { Icon(painterResource(R.drawable.more_vert), null) } } }, ) } if (isLoadingMore) { item(key = "loading_more") { Box( modifier = Modifier .fillMaxWidth() .padding(16.dp), contentAlignment = Alignment.Center, ) { ContainedLoadingIndicator() } } } } } } TopAppBar( title = { if (inSelectMode) { Text( text = if (isPodcastPlaylist) { pluralStringResource(R.plurals.n_episode, selection.size, selection.size) } else { pluralStringResource(R.plurals.n_song, selection.size, selection.size) }, 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 (lazyListState.firstVisibleItemIndex > 0) { Text(playlist?.title ?: "") } }, navigationIcon = { IconButton( onClick = { if (isSearching) { isSearching = false query = TextFieldValue() } else if (inSelectMode) { onExitSelectionMode() } else { navController.navigateUp() } }, onLongClick = { if (!isSearching && !inSelectMode) { navController.backToMain() } }, ) { Icon( painter = painterResource( if (inSelectMode) R.drawable.close else R.drawable.arrow_back, ), contentDescription = null, ) } }, actions = { if (inSelectMode) { Checkbox( checked = selection.size == filteredSongs.size && selection.isNotEmpty(), onCheckedChange = { if (selection.size == filteredSongs.size) { selection.clear() } else { selection.clear() selection.addAll(filteredSongs.map { it.second.id }) } }, ) IconButton( enabled = selection.isNotEmpty(), onClick = { menuState.show { YouTubeSelectionSongMenu( songSelection = filteredSongs .filter { it.second.id in selection } .map { it.second }, onDismiss = menuState::dismiss, clearAction = onExitSelectionMode, ) } }, ) { 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.align(Alignment.BottomCenter), ) } } @Composable private fun OnlinePlaylistHeader( playlist: PlaylistItem, songs: List, dbPlaylist: Playlist?, navController: NavController, coroutineScope: CoroutineScope, continuation: String?, isPodcastPlaylist: Boolean = false, modifier: Modifier = Modifier, ) { val playerConnection = LocalPlayerConnection.current ?: return val listenTogetherManager = LocalListenTogetherManager.current val isListenTogetherGuest = listenTogetherManager?.let { it.isInRoom && !it.isHost } ?: false val database = LocalDatabase.current val menuState = LocalMenuState.current val syncUtils = LocalSyncUtils.current Column( modifier = modifier .fillMaxWidth() .padding(top = 8.dp, bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Surface( modifier = Modifier .size(240.dp) .shadow( elevation = 24.dp, shape = RoundedCornerShape(3.dp), spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), ), shape = RoundedCornerShape(3.dp), ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current).data(playlist.thumbnail).build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) } Spacer(modifier = Modifier.height(20.dp)) Text( text = playlist.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 32.dp), ) Spacer(modifier = Modifier.height(12.dp)) // Metadata - Song Count • Duration val totalDuration = songs.sumOf { it.duration ?: 0 } Text( text = buildString { append( if (isPodcastPlaylist) { pluralStringResource(R.plurals.n_episode, songs.size, songs.size) } else { pluralStringResource(R.plurals.n_song, songs.size, songs.size) }, ) if (totalDuration > 0) { append(" • ") append(makeTimeString(totalDuration * 1000L)) } }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), ) Spacer(modifier = Modifier.height(24.dp)) Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically, ) { // Like Button - Smaller secondary button Surface( onClick = { if (dbPlaylist != null) { database.transaction { val currentPlaylist = dbPlaylist.playlist update(currentPlaylist, playlist) update(currentPlaylist.toggleLike()) } } else { database.transaction { val playlistEntity = PlaylistEntity( name = playlist.title, browseId = playlist.id, thumbnailUrl = playlist.thumbnail, isEditable = playlist.isEditable, 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 .map { it.toMediaMetadata() } .onEach(::insert) .mapIndexed { index, song -> PlaylistSongMap( songId = song.id, playlistId = playlistEntity.id, position = index, setVideoId = song.setVideoId, ) }.forEach(::insert) } } } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp), ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { 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 { MaterialTheme.colorScheme.onSurfaceVariant }, modifier = Modifier.size(24.dp), ) } } // Play Button - Larger primary circular button Surface( onClick = { if (!isListenTogetherGuest && songs.isNotEmpty()) { playerConnection.playQueue( YouTubePlaylistQueue( playlistId = playlist.id, playlistTitle = playlist.title, initialSongs = songs, initialContinuation = continuation, ), ) } }, color = MaterialTheme.colorScheme.primary, shape = CircleShape, modifier = Modifier.size(72.dp), ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(), ) { Icon( painter = painterResource(R.drawable.play), contentDescription = stringResource(R.string.play), tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(32.dp), ) } } // Menu Button - Smaller secondary button Surface( onClick = { menuState.show { YouTubePlaylistMenu( playlist = playlist, songs = songs, coroutineScope = coroutineScope, onDismiss = menuState::dismiss, ) } }, shape = CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp), ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, modifier = Modifier.size(24.dp), ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/playlist/TopPlaylistScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.playlist import androidx.activity.compose.BackHandler 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.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.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.Checkbox 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.TextButton 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.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow 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.util.fastForEachReversed import androidx.compose.ui.util.fastSumBy import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.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 coil3.compose.AsyncImage import com.metrolist.music.LocalDownloadUtil import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.MyTopFilter import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.ExoDownloadService import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.DraggableScrollbar import com.metrolist.music.ui.component.EmptyPlaceholder import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.component.SortHeader import com.metrolist.music.ui.menu.SelectionSongMenu import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.ui.menu.TopPlaylistMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.makeTimeString import com.metrolist.music.viewmodels.TopPlaylistViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun TopPlaylistScreen( navController: NavController, viewModel: TopPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val focusManager = LocalFocusManager.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.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 } var isSearching by remember { mutableStateOf(false) } var query by remember { mutableStateOf(TextFieldValue()) } val focusRequester = remember { FocusRequester() } LaunchedEffect(isSearching) { if (isSearching) { focusRequester.requestFocus() } } var inSelectMode by rememberSaveable { mutableStateOf(false) } val selection = rememberSaveable( saver = listSaver, String>( save = { it.toList() }, restore = { it.toMutableStateList() } ) ) { mutableStateListOf() } var selectionAnchorSongId by rememberSaveable { mutableStateOf(null) } val onExitSelectionMode = { inSelectMode = false selection.clear() selectionAnchorSongId = null } val filteredSongs = remember(songs, query) { if (query.text.isEmpty()) songs ?: emptyList() else songs?.filter { song -> song.title.contains(query.text, true) || song.artists.any { it.name.contains(query.text, true) } } ?: emptyList() } LaunchedEffect(filteredSongs) { selection.fastForEachReversed { songId -> if (filteredSongs.find { it.id == songId } == null) { selection.remove(songId) } } if (selectionAnchorSongId != null && filteredSongs.none { it.id == selectionAnchorSongId }) { selectionAnchorSongId = filteredSongs.firstOrNull { it.id in selection }?.id } } if (isSearching) { BackHandler { isSearching = false query = TextFieldValue() } } else if (inSelectMode) { BackHandler(onBack = onExitSelectionMode) } val sortType by viewModel.topPeriod.collectAsState() val name = stringResource(R.string.my_top) + " $maxSize" val downloadUtil = LocalDownloadUtil.current var downloadState by remember { mutableIntStateOf(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(key = "empty_placeholder") { EmptyPlaceholder( icon = R.drawable.music_note, text = stringResource(R.string.playlist_is_empty), ) } } else { if (!isSearching) { item(key = "playlist_header") { TopPlaylistHeader( name = name, songs = songs!!, likeLength = likeLength, downloadState = downloadState, onShowRemoveDownloadDialog = { showRemoveDownloadDialog = true }, menuState = menuState, modifier = Modifier.animateItem() ) } } item(key = "songs_header") { 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, ) } } } if (filteredSongs.isNotEmpty()) { itemsIndexed( items = filteredSongs, key = { _, song -> song.id }, ) { index, song -> val onCheckedChange: (Boolean) -> Unit = { if (it) { selection.add(song.id) } else { selection.remove(song.id) } } SongListItem( song = song, albumIndex = index + 1, isActive = song.song.id == mediaMetadata?.id, isPlaying = isPlaying, showInLibraryIcon = true, trailingContent = { if (inSelectMode) { Checkbox( checked = song.id in selection, onCheckedChange = onCheckedChange ) } else { 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 (inSelectMode) { onCheckedChange(song.id !in selection) } else if (song.song.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( ListQueue( title = name, items = songs!!.map { it.toMediaItem() }, startIndex = songs!!.indexOfFirst { it.id == song.id } ), ) } }, onLongClick = { if (!inSelectMode) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) inSelectMode = true onCheckedChange(true) selectionAnchorSongId = song.id } else { val anchorIndex = selectionAnchorSongId?.let { anchorSongId -> filteredSongs.indexOfFirst { it.id == anchorSongId } } ?: -1 if (anchorIndex == -1) { onCheckedChange(true) selectionAnchorSongId = song.id } else { val range = if (anchorIndex <= index) anchorIndex..index else index..anchorIndex for (rangeIndex in range) { val rangeSongId = filteredSongs[rangeIndex].id if (rangeSongId !in selection) { selection.add(rangeSongId) } } } } }, ) .animateItem() ) } } } } DraggableScrollbar( modifier = Modifier .padding( LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime) .asPaddingValues() ) .align(Alignment.CenterEnd), scrollState = state, headerItems = 2 ) TopAppBar( title = { when { inSelectMode -> { Text( text = pluralStringResource(R.plurals.n_song, selection.size, selection.size), 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(text = name) } } }, navigationIcon = { IconButton( onClick = { when { isSearching -> { isSearching = false query = TextFieldValue() focusManager.clearFocus() } inSelectMode -> { onExitSelectionMode() } else -> { navController.navigateUp() } } }, onLongClick = { if (!isSearching && !inSelectMode) { navController.backToMain() } } ) { Icon( painter = painterResource( if (inSelectMode) R.drawable.close else R.drawable.arrow_back ), contentDescription = null ) } }, actions = { if (inSelectMode) { Checkbox( checked = selection.size == filteredSongs.size && selection.isNotEmpty(), onCheckedChange = { if (selection.size == filteredSongs.size) { selection.clear() } else { selection.clear() selection.addAll(filteredSongs.map { it.id }) } } ) IconButton( enabled = selection.isNotEmpty(), onClick = { menuState.show { SelectionSongMenu( songSelection = filteredSongs.filter { it.id in selection }, onDismiss = menuState::dismiss, clearAction = onExitSelectionMode, ) } }, ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null ) } } else if (!isSearching) { IconButton( onClick = { isSearching = true } ) { Icon( painter = painterResource(R.drawable.search), contentDescription = null ) } } } ) } } @Composable private fun TopPlaylistHeader( name: String, songs: List, likeLength: Int, downloadState: Int, onShowRemoveDownloadDialog: () -> Unit, menuState: com.metrolist.music.ui.component.MenuState, modifier: Modifier = Modifier ) { val playerConnection = LocalPlayerConnection.current ?: return val context = LocalContext.current Column( modifier = modifier .fillMaxWidth() .padding(top = 8.dp, bottom = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // Playlist Thumbnail - Large centered with shadow Box( modifier = Modifier.padding(top = 8.dp, bottom = 20.dp) ) { androidx.compose.material3.Surface( modifier = Modifier .size(240.dp) .shadow( elevation = 24.dp, shape = RoundedCornerShape(3.dp), spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) ), shape = RoundedCornerShape(3.dp) ) { AsyncImage( model = songs[0].thumbnailUrl, contentDescription = null, contentScale = androidx.compose.ui.layout.ContentScale.Crop, modifier = Modifier.fillMaxSize() ) } } // Playlist Name Text( text = name, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = androidx.compose.ui.text.style.TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 32.dp) ) Spacer(modifier = Modifier.height(12.dp)) // Metadata - Song Count • Duration Text( text = buildString { append(pluralStringResource(R.plurals.n_song, songs.size, songs.size)) if (likeLength > 0) { append(" • ") append(makeTimeString(likeLength * 1000L)) } }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) ) Spacer(modifier = Modifier.height(24.dp)) // Action Buttons Row Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically ) { // Shuffle Button - Smaller secondary button androidx.compose.material3.Surface( onClick = { playerConnection.playQueue( ListQueue( title = name, items = songs.shuffled().map { it.toMediaItem() }, ), ) }, shape = androidx.compose.foundation.shape.CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(R.drawable.shuffle), contentDescription = stringResource(R.string.shuffle), modifier = Modifier.size(24.dp) ) } } // Play Button - Larger primary circular button Surface( onClick = { playerConnection.playQueue( ListQueue( title = name, items = songs.map { it.toMediaItem() }, ), ) }, color = MaterialTheme.colorScheme.primary, shape = androidx.compose.foundation.shape.CircleShape, modifier = Modifier.size(72.dp) ) { Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize() ) { Icon( painter = painterResource(R.drawable.play), contentDescription = stringResource(R.string.play), tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(32.dp) ) } } // Menu Button - Smaller secondary button androidx.compose.material3.Surface( onClick = { menuState.show { TopPlaylistMenu( downloadState = downloadState, onQueue = { playerConnection.addToQueue( songs.map { it.toMediaItem() } ) }, onDownload = { when (downloadState) { Download.STATE_COMPLETED -> onShowRemoveDownloadDialog() Download.STATE_DOWNLOADING -> { songs.forEach { song -> DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, song.id, false, ) } } else -> { 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, ) } } } }, onDismiss = { menuState.dismiss() } ) } }, shape = androidx.compose.foundation.shape.CircleShape, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(48.dp) ) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null, modifier = Modifier.size(24.dp) ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/podcast/OnlinePodcastScreen.kt ================================================ package com.metrolist.music.ui.screens.podcast import androidx.activity.compose.BackHandler 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.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.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.ContainedLoadingIndicator import androidx.compose.material3.LocalContentColor import androidx.compose.material3.OutlinedButton import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 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.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.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.input.ImeAction 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.compose.ui.util.fastAny import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import coil3.compose.AsyncImage import coil3.request.ImageRequest import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PodcastItem import timber.log.Timber import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.db.entities.PodcastEntity import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.viewmodels.OnlinePodcastViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun OnlinePodcastScreen( navController: NavController, scrollBehavior: TopAppBarScrollBehavior, viewModel: OnlinePodcastViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val haptic = LocalHapticFeedback.current val playerConnection = LocalPlayerConnection.current ?: return val database = LocalDatabase.current val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val podcast by viewModel.podcast.collectAsState() val episodes by viewModel.episodes.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val error by viewModel.error.collectAsState() val libraryPodcast by viewModel.libraryPodcast.collectAsState() val lazyListState = rememberLazyListState() var isSearching by rememberSaveable { mutableStateOf(false) } var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } val filteredEpisodes = remember(episodes, query) { if (query.text.isEmpty()) episodes else episodes.filter { episode -> episode.title.contains(query.text, ignoreCase = true) || episode.author?.name?.contains(query.text, ignoreCase = true) == true } } val focusRequester = remember { FocusRequester() } LaunchedEffect(isSearching) { if (isSearching) focusRequester.requestFocus() } if (isSearching) { BackHandler { isSearching = false query = TextFieldValue() } } Box(Modifier.fillMaxSize()) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.union(WindowInsets.ime).asPaddingValues(), ) { if (podcast == null && isLoading) { item(key = "loading_placeholder") { Box( modifier = Modifier .fillMaxWidth() .padding(32.dp), contentAlignment = Alignment.Center ) { ContainedLoadingIndicator() } } } else if (error != null) { item(key = "error") { Column( modifier = Modifier .fillMaxWidth() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = error ?: stringResource(R.string.error_unknown), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) Button(onClick = { viewModel.retry() }) { Text(stringResource(R.string.retry)) } } } } else { podcast?.let { podcastItem -> if (!isSearching) { item(key = "podcast_header") { val context = LocalContext.current PodcastHeader( podcast = podcastItem, episodeCount = episodes.size, inLibrary = libraryPodcast?.inLibrary == true, onLibraryClick = { viewModel.toggleLibrary() }, onViewChannelClick = { val channelId = podcastItem.channelId ?: podcastItem.author?.id if (channelId != null) { navController.navigate("artist/$channelId?isPodcastChannel=true") } } ) } } itemsIndexed( items = filteredEpisodes, key = { _, episode -> episode.id } ) { index, episode -> YouTubeListItem( item = episode, isActive = mediaMetadata?.id == episode.id, isPlaying = isPlaying, modifier = Modifier .combinedClickable( onClick = { if (episode.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { Timber.d("Playing episode: ${episode.title}, index: $index, total episodes: ${filteredEpisodes.size}") val mediaItems = filteredEpisodes.map { it.toMediaMetadata().toMediaItem() } Timber.d("Created ${mediaItems.size} media items for queue") playerConnection.playQueue( ListQueue( title = podcast?.title, items = mediaItems, startIndex = index ) ) } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { YouTubeSongMenu(episode.asSongItem(), navController, menuState::dismiss) } } ) .animateItem(), trailingContent = { IconButton(onClick = { menuState.show { YouTubeSongMenu(episode.asSongItem(), navController, menuState::dismiss) } }) { Icon(painterResource(R.drawable.more_vert), null) } } ) } } } } TopAppBar( title = { 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 (lazyListState.firstVisibleItemIndex > 0) { Text(podcast?.title ?: "") } }, navigationIcon = { IconButton( onClick = { if (isSearching) { isSearching = false query = TextFieldValue() } else { navController.navigateUp() } }, onLongClick = { if (!isSearching) navController.backToMain() } ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = null ) } }, actions = { if (!isSearching) { IconButton(onClick = { isSearching = true }) { Icon( painter = painterResource(R.drawable.search), contentDescription = stringResource(R.string.search) ) } } }, scrollBehavior = scrollBehavior ) } } @Composable private fun PodcastHeader( podcast: PodcastItem, episodeCount: Int, inLibrary: Boolean, onLibraryClick: () -> Unit, onViewChannelClick: () -> Unit ) { val context = LocalContext.current Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { AsyncImage( model = ImageRequest.Builder(context) .data(podcast.thumbnail) .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(200.dp) .clip(RoundedCornerShape(8.dp)) ) Spacer(modifier = Modifier.height(16.dp)) Text( text = podcast.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis ) podcast.author?.name?.let { authorName -> Spacer(modifier = Modifier.height(4.dp)) Text( text = authorName, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(4.dp)) Text( text = podcast.episodeCountText ?: "$episodeCount episodes", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(16.dp)) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { OutlinedButton( onClick = onLibraryClick, colors = ButtonDefaults.outlinedButtonColors( containerColor = if (inLibrary) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent ), shape = RoundedCornerShape(50), modifier = Modifier.height(40.dp) ) { Icon( painter = painterResource(if (inLibrary) R.drawable.library_add_check else R.drawable.library_add), contentDescription = null, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(if (inLibrary) R.string.remove_from_library else R.string.add_to_library) ) } OutlinedButton( onClick = onViewChannelClick, shape = RoundedCornerShape(50), modifier = Modifier.height(40.dp) ) { Icon( painter = painterResource(R.drawable.person), contentDescription = null, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(R.string.view_channel) ) } } Spacer(modifier = Modifier.height(16.dp)) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/recognition/RecognitionHistoryScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.recognition 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.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.only 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.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar 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.draw.clip import androidx.compose.ui.layout.ContentScale 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.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.ThumbnailCornerRadius import com.metrolist.music.db.entities.RecognitionHistory import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.utils.backToMain import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.time.format.DateTimeFormatter @OptIn(ExperimentalMaterial3Api::class) @Composable fun RecognitionHistoryScreen( navController: NavController ) { val database = LocalDatabase.current val menuState = LocalMenuState.current val coroutineScope = rememberCoroutineScope() val historyItems by database.recognitionHistory().collectAsState(initial = emptyList()) var showClearDialog by remember { mutableStateOf(false) } var itemToDelete by remember { mutableStateOf(null) } if (showClearDialog) { DefaultDialog( onDismiss = { showClearDialog = false }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null ) }, title = { Text(stringResource(R.string.clear_recognition_history)) }, buttons = { TextButton(onClick = { showClearDialog = false }) { Text(stringResource(R.string.cancel)) } TextButton( onClick = { coroutineScope.launch(Dispatchers.IO) { database.query { clearRecognitionHistory() } } showClearDialog = false } ) { Text(stringResource(R.string.clear)) } } ) { Text( text = stringResource(R.string.clear_recognition_history_confirm), style = MaterialTheme.typography.bodyMedium ) } } itemToDelete?.let { item -> DefaultDialog( onDismiss = { itemToDelete = null }, icon = { Icon( painter = painterResource(R.drawable.delete), contentDescription = null ) }, title = { Text(stringResource(R.string.delete)) }, buttons = { TextButton(onClick = { itemToDelete = null }) { Text(stringResource(R.string.cancel)) } TextButton( onClick = { coroutineScope.launch(Dispatchers.IO) { database.query { deleteRecognitionHistoryById(item.id) } } itemToDelete = null } ) { Text(stringResource(R.string.delete)) } } ) { Text( text = stringResource(R.string.delete_playlist_confirm, item.title), style = MaterialTheme.typography.bodyMedium ) } } Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.recognition_history)) }, navigationIcon = { IconButton( onClick = { navController.navigateUp() }, onLongClick = { navController.backToMain() } ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = null ) } }, actions = { if (historyItems.isNotEmpty()) { IconButton(onClick = { showClearDialog = true }) { Icon( painter = painterResource(R.drawable.clear_all), contentDescription = stringResource(R.string.clear_recognition_history) ) } } } ) } ) { paddingValues -> if (historyItems.isEmpty()) { Box( modifier = Modifier .fillMaxSize() .padding(paddingValues), contentAlignment = Alignment.Center ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(R.drawable.history), contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "No recognition history", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } else { LazyColumn( modifier = Modifier .fillMaxSize() .padding(paddingValues), contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Bottom) .asPaddingValues() ) { items( items = historyItems, key = { it.id } ) { item -> RecognitionHistoryItem( item = item, onClick = { // Search for the track on YouTube Music val searchQuery = "${item.title} ${item.artist}" navController.navigate("search/${java.net.URLEncoder.encode(searchQuery, "UTF-8")}") }, onDelete = { itemToDelete = item } ) } } } } } @Composable private fun RecognitionHistoryItem( item: RecognitionHistory, onClick: () -> Unit, onDelete: () -> Unit ) { val dateFormatter = remember { DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm") } Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) .clickable { onClick() }, shape = RoundedCornerShape(ThumbnailCornerRadius), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ) ) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { // Album art AsyncImage( model = item.coverArtUrl, contentDescription = null, modifier = Modifier .size(60.dp) .clip(RoundedCornerShape(ThumbnailCornerRadius)), contentScale = ContentScale.Crop ) Spacer(modifier = Modifier.width(12.dp)) // Track info Column( modifier = Modifier.weight(1f) ) { Text( text = item.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = item.artist, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) Text( text = item.recognizedAt.format(dateFormatter), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) } // Delete action IconButton(onClick = onDelete) { Icon( painter = painterResource(R.drawable.delete), contentDescription = stringResource(R.string.delete_from_history), tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/recognition/RecognitionScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.recognition import android.Manifest import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing 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.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith 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.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.Button 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.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar 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.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.draw.scale import androidx.compose.ui.graphics.Brush 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.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.content.ContextCompat import androidx.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.music.LocalDatabase import com.metrolist.music.R import com.metrolist.music.db.entities.RecognitionHistory import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.utils.backToMain import com.metrolist.shazamkit.models.RecognitionResult import com.metrolist.shazamkit.models.RecognitionStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.time.LocalDateTime @OptIn(ExperimentalMaterial3Api::class) @Composable fun RecognitionScreen( navController: NavController, autoStart: Boolean = false, ) { val context = LocalContext.current val database = LocalDatabase.current val coroutineScope = rememberCoroutineScope() // Only reset in Ready state: Listening/Processing belong to a running widget-service // recognition that must not be cancelled; Success/NoMatch/Error are results pending // display and history saving. LaunchedEffect(Unit) { if (com.metrolist.music.recognition.MusicRecognitionService.recognitionStatus.value is RecognitionStatus.Ready ) { com.metrolist.music.recognition.MusicRecognitionService .reset() } } DisposableEffect(Unit) { onDispose { com.metrolist.music.recognition.MusicRecognitionService .reset() } } // Observe recognition status from service for real-time updates (Listening -> Processing -> Result) val recognitionStatus by com.metrolist.music.recognition.MusicRecognitionService.recognitionStatus .collectAsState() var hasPermission by remember { mutableStateOf( ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, ) } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { isGranted -> hasPermission = isGranted if (isGranted) { coroutineScope.launch { com.metrolist.music.recognition.MusicRecognitionService .recognize(context) } } } fun startRecognition() { if (hasPermission) { coroutineScope.launch { com.metrolist.music.recognition.MusicRecognitionService .recognize(context) } } else { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } } LaunchedEffect(Unit) { if (autoStart && com.metrolist.music.recognition.MusicRecognitionService.recognitionStatus.value is RecognitionStatus.Ready ) { startRecognition() } } fun resetToReady() { com.metrolist.music.recognition.MusicRecognitionService .reset() } fun saveToHistory(result: RecognitionResult) { // Skip if the widget service already persisted this result to avoid a duplicate entry if (com.metrolist.music.recognition.MusicRecognitionService.resultSavedExternally) return coroutineScope.launch(Dispatchers.IO) { database.query { insert( RecognitionHistory( trackId = result.trackId, title = result.title, artist = result.artist, album = result.album, coverArtUrl = result.coverArtUrl, coverArtHqUrl = result.coverArtHqUrl, genre = result.genre, releaseDate = result.releaseDate, label = result.label, shazamUrl = result.shazamUrl, appleMusicUrl = result.appleMusicUrl, spotifyUrl = result.spotifyUrl, isrc = result.isrc, youtubeVideoId = result.youtubeVideoId, recognizedAt = LocalDateTime.now(), ), ) } } } Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.recognize_music)) }, navigationIcon = { IconButton( onClick = { navController.navigateUp() }, onLongClick = { navController.backToMain() }, ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, actions = { IconButton(onClick = { navController.navigate("recognition_history") }) { Icon( painter = painterResource(R.drawable.history), contentDescription = stringResource(R.string.recognition_history), ) } }, ) }, ) { paddingValues -> Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { AnimatedContent( targetState = recognitionStatus, transitionSpec = { (fadeIn() + scaleIn()).togetherWith(fadeOut() + scaleOut()) }, label = "recognition_content", ) { status -> when (status) { is RecognitionStatus.Ready -> { ReadyState(onStartRecognition = ::startRecognition) } is RecognitionStatus.Listening -> { ListeningState( onCancel = { com.metrolist.music.recognition.MusicRecognitionService .reset() }, ) } is RecognitionStatus.Processing -> { ProcessingState() } is RecognitionStatus.Success -> { SuccessState( result = status.result, onPlayOnApp = { result -> // Search for the track on YouTube Music val searchQuery = "${result.title} ${result.artist}" navController.navigate("search/${java.net.URLEncoder.encode(searchQuery, "UTF-8")}") }, onTryAgain = { startRecognition() }, onClose = ::resetToReady, onSaveToHistory = ::saveToHistory, ) } is RecognitionStatus.NoMatch -> { NoMatchState( message = status.message, onTryAgain = { startRecognition() }, ) } is RecognitionStatus.Error -> { ErrorState( message = status.message, onTryAgain = { startRecognition() }, ) } } } } } } @Composable private fun ReadyState(onStartRecognition: () -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), ) { Box( modifier = Modifier .size(200.dp) .clip(CircleShape) .background( Brush.radialGradient( colors = listOf( MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), Color.Transparent, ), ), ).clickable { onStartRecognition() }, contentAlignment = Alignment.Center, ) { Box( modifier = Modifier .size(160.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.mic), contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onPrimary, ) } } Text( text = stringResource(R.string.tap_to_recognize), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) } } @Composable private fun ListeningState(onCancel: () -> Unit) { val infiniteTransition = rememberInfiniteTransition(label = "pulse") val scale by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 1.2f, animationSpec = infiniteRepeatable( animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse, ), label = "scale", ) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), ) { // Container large enough for scaled animation (200dp * 1.2 = 240dp) Box( modifier = Modifier.size(260.dp), contentAlignment = Alignment.Center, ) { // Outer pulsing ring Box( modifier = Modifier .size(200.dp) .scale(scale) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), ) // Inner pulsing ring Box( modifier = Modifier .size(180.dp) .scale(scale * 0.9f) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)), ) // Main button Box( modifier = Modifier .size(160.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.primary) .clickable { onCancel() }, contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.mic), contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onPrimary, ) } } Text( text = stringResource(R.string.listening), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary, ) OutlinedButton(onClick = onCancel) { Text(stringResource(R.string.cancel)) } } } @Composable private fun ProcessingState() { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), ) { val infiniteTransition = rememberInfiniteTransition(label = "rotate") val rotation by infiniteTransition.animateFloat( initialValue = 0f, targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(2000, easing = LinearEasing), ), label = "rotation", ) Box( modifier = Modifier.size(160.dp), contentAlignment = Alignment.Center, ) { Box( modifier = Modifier .size(160.dp) .clip(CircleShape) .border( width = 4.dp, brush = Brush.sweepGradient( colors = listOf( MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), Color.Transparent, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), MaterialTheme.colorScheme.primary, ), ), shape = CircleShape, ), ) Icon( painter = painterResource(R.drawable.music_note), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.primary, ) } Text( text = stringResource(R.string.processing), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, ) } } @Composable private fun SuccessState( result: RecognitionResult, onPlayOnApp: (RecognitionResult) -> Unit, onTryAgain: () -> Unit, onClose: () -> Unit, onSaveToHistory: (RecognitionResult) -> Unit, ) { // Save to history when success is shown LaunchedEffect(result) { onSaveToHistory(result) } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(horizontal = 16.dp), ) { // Album art Card( modifier = Modifier .size(180.dp) .aspectRatio(1f), shape = RoundedCornerShape(com.metrolist.music.constants.ThumbnailCornerRadius), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), ) { AsyncImage( model = result.coverArtHqUrl ?: result.coverArtUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, ) } Spacer(modifier = Modifier.height(8.dp)) // Track info Text( text = result.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, maxLines = 2, overflow = TextOverflow.Ellipsis, ) Text( text = result.artist, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, ) result.album?.let { album -> Text( text = album, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } Spacer(modifier = Modifier.height(16.dp)) // Action buttons - stacked vertically Column( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), ) { Button( onClick = { onPlayOnApp(result) }, modifier = Modifier.fillMaxWidth(), ) { Icon( painter = painterResource(R.drawable.play), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.play_on_app)) } FilledTonalButton( onClick = onTryAgain, modifier = Modifier.fillMaxWidth(), ) { Icon( painter = painterResource(R.drawable.mic), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.re_listen)) } // Close button - Material 3 Expressive outlined style OutlinedButton( onClick = onClose, modifier = Modifier.fillMaxWidth(), ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.close)) } } } } @Composable private fun NoMatchState( message: String, onTryAgain: () -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), ) { Box( modifier = Modifier .size(120.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.errorContainer), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onErrorContainer, ) } Text( text = stringResource(R.string.no_match_found), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) Text( text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 32.dp), ) Button(onClick = onTryAgain) { Icon( painter = painterResource(R.drawable.refresh), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.try_again)) } } } @Composable private fun ErrorState( message: String, onTryAgain: () -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), ) { Box( modifier = Modifier .size(120.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.errorContainer), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.error), contentDescription = null, modifier = Modifier.size(48.dp), tint = MaterialTheme.colorScheme.onErrorContainer, ) } Text( text = stringResource(R.string.recognition_error), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) Text( text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 32.dp), ) Button(onClick = onTryAgain) { Icon( painter = painterResource(R.drawable.refresh), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(8.dp)) Text(stringResource(R.string.try_again)) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/search/LocalSearchScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars 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.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.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.CONTENT_TYPE_LIST import com.metrolist.music.constants.ListItemHeight import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Artist import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.Song import com.metrolist.music.extensions.toMediaItem import com.metrolist.music.playback.queues.ListQueue import com.metrolist.music.ui.component.AlbumListItem import com.metrolist.music.ui.component.ArtistListItem import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.ui.component.EmptyPlaceholder import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.PlaylistListItem import com.metrolist.music.ui.component.SongListItem import com.metrolist.music.ui.menu.SongMenu import com.metrolist.music.viewmodels.LocalFilter import com.metrolist.music.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 queueSearchedSongsStr = stringResource(R.string.queue_searched_songs) val keyboardController = LocalSoftwareKeyboardController.current val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isEffectivelyPlaying.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 } val configuration = LocalWindowInfo.current val isLandscape = configuration.containerSize.width > configuration.containerSize.height Column( modifier = Modifier .fillMaxSize() .background(if (pureBlack) Color.Black else MaterialTheme.colorScheme.background) .let { base -> if (isLandscape) { base.windowInsetsPadding( WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) } else { base } }, ) { 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), contentPadding = WindowInsets.systemBars .only(WindowInsetsSides.Bottom) .asPaddingValues(), ) { 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.distinctBy { it.id }, 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.togglePlayPause() } else { val songs = result.map .getOrDefault(LocalFilter.SONG, emptyList()) .filterIsInstance() .map { it.toMediaItem() } playerConnection.playQueue( ListQueue( title = queueSearchedSongsStr, 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), ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/search/OnlineSearchResult.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.search 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.Column 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.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions 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.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM import com.metrolist.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST import com.metrolist.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_PLAYLIST import com.metrolist.innertube.YouTube.SearchFilter.Companion.FILTER_EPISODE import com.metrolist.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PLAYLIST import com.metrolist.innertube.YouTube.SearchFilter.Companion.FILTER_PODCAST import com.metrolist.innertube.YouTube.SearchFilter.Companion.FILTER_PROFILE import com.metrolist.innertube.YouTube.SearchFilter.Companion.FILTER_SONG import com.metrolist.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.innertube.models.YTItem import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.MiniPlayerBottomSpacing import com.metrolist.music.constants.MiniPlayerHeight import com.metrolist.music.constants.NavigationBarHeight import com.metrolist.music.constants.PauseSearchHistoryKey import com.metrolist.music.db.entities.SearchHistory import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.ChipsRow import com.metrolist.music.ui.component.EmptyPlaceholder import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.NavigationTitle import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.component.shimmer.ListItemPlaceHolder import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.menu.YouTubeArtistMenu import com.metrolist.music.ui.menu.YouTubePlaylistMenu import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.OnlineSearchViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.net.URLDecoder import java.net.URLEncoder @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun OnlineSearchResult( navController: NavController, viewModel: OnlineSearchViewModel = hiltViewModel(), pureBlack: Boolean = false, ) { val database = LocalDatabase.current val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return val haptic = LocalHapticFeedback.current val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val coroutineScope = rememberCoroutineScope() val lazyListState = rememberLazyListState() val focusManager = LocalFocusManager.current val focusRequester = remember { FocusRequester() } var isSearchFocused by remember { mutableStateOf(false) } val pauseSearchHistory by rememberPreference(PauseSearchHistoryKey, defaultValue = false) val hideVideoSongs by rememberPreference(HideVideoSongsKey, defaultValue = false) BackHandler(enabled = isSearchFocused) { isSearchFocused = false focusManager.clearFocus() } // Extract query from navigation arguments val encodedQuery = navController.currentBackStackEntry?.arguments?.getString("query") ?: "" val decodedQuery = remember(encodedQuery) { try { URLDecoder.decode(encodedQuery, "UTF-8") } catch (e: Exception) { encodedQuery } } var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(decodedQuery, TextRange(decodedQuery.length))) } val onSearch: (String) -> Unit = remember { { searchQuery -> if (searchQuery.isNotEmpty()) { isSearchFocused = false focusManager.clearFocus() navController.navigate("search/${URLEncoder.encode(searchQuery, "UTF-8")}") { popUpTo("search/${URLEncoder.encode(decodedQuery, "UTF-8")}") { inclusive = true } if (!pauseSearchHistory) { coroutineScope.launch(Dispatchers.IO) { database.query { insert(SearchHistory(query = searchQuery)) } } } } } } } // Update query when decodedQuery changes LaunchedEffect(decodedQuery) { query = TextFieldValue(decodedQuery, TextRange(decodedQuery.length)) } // Clear video filter if hideVideoSongs setting is enabled and filter is set to FILTER_VIDEO LaunchedEffect(hideVideoSongs) { if (hideVideoSongs && viewModel.filter.value == FILTER_VIDEO) { viewModel.filter.value = null } } val searchFilter by viewModel.filter.collectAsState() val searchSummary = viewModel.summaryPage val itemsPage by remember(searchFilter) { derivedStateOf { searchFilter?.value?.let { viewModel.viewStateMap[it] } } } LaunchedEffect(lazyListState) { snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } }.collect { shouldLoadMore -> if (!shouldLoadMore) return@collect viewModel.loadMore() } } val ytItemContent: @Composable LazyItemScope.(YTItem) -> Unit = { item: YTItem -> 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, ) } is PodcastItem -> { YouTubePlaylistMenu( playlist = item.asPlaylistItem(), coroutineScope = coroutineScope, onDismiss = menuState::dismiss, ) } is EpisodeItem -> { YouTubeSongMenu( song = item.asSongItem(), navController = navController, onDismiss = menuState::dismiss, ) } } } } YouTubeListItem( item = item, isActive = when (item) { is SongItem -> mediaMetadata?.id == item.id is AlbumItem -> mediaMetadata?.album?.id == item.id is EpisodeItem -> mediaMetadata?.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.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}") } is PodcastItem -> { navController.navigate("online_podcast/${item.id}") } is EpisodeItem -> { if (item.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue( WatchEndpoint(videoId = item.id), item.toMediaMetadata(), ), ) } } } }, onLongClick = longClick, ).animateItem(), ) } Column( modifier = Modifier .fillMaxSize() .background(if (pureBlack) Color.Black else MaterialTheme.colorScheme.background) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), ) { // Google-style SearchBar with Material 3 design OutlinedTextField( value = query, onValueChange = { newQuery -> query = newQuery }, placeholder = { Text( text = stringResource(R.string.search_yt_music), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, leadingIcon = { IconButton( onClick = { navController.navigateUp() }, ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = stringResource(R.string.dismiss), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } }, trailingIcon = { if (query.text.isNotEmpty()) { IconButton( onClick = { query = TextFieldValue("") }, ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Search, ), keyboardActions = KeyboardActions( onSearch = { onSearch(query.text) }, ), singleLine = true, shape = RoundedCornerShape(28.dp), colors = OutlinedTextFieldDefaults.colors( focusedContainerColor = if (pureBlack) { MaterialTheme.colorScheme.surface } else { MaterialTheme.colorScheme.surfaceContainerHigh }, unfocusedContainerColor = if (pureBlack) { MaterialTheme.colorScheme.surface } else { MaterialTheme.colorScheme.surfaceContainerHigh }, focusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent, ), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) .focusRequester(focusRequester) .onFocusChanged { focusState -> if (focusState.isFocused) { isSearchFocused = true } }, ) // Main content area below search bar Box(modifier = Modifier.weight(1f)) { Column( modifier = Modifier.fillMaxWidth(), ) { val visibleChips = listOf( null to stringResource(R.string.filter_all), FILTER_SONG to stringResource(R.string.filter_songs), ).let { baseChips -> if (!hideVideoSongs) { baseChips + (FILTER_VIDEO to stringResource(R.string.filter_videos)) } else { baseChips } } + listOf( 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), FILTER_PODCAST to stringResource(R.string.filter_podcasts), FILTER_EPISODE to stringResource(R.string.filter_episodes), FILTER_PROFILE to stringResource(R.string.filter_profiles), ) ChipsRow( chips = visibleChips, currentValue = searchFilter, onValueUpdate = { if (viewModel.filter.value != it) { viewModel.filter.value = it } coroutineScope.launch { lazyListState.animateScrollToItem(0) } }, modifier = Modifier.fillMaxWidth(), ) LazyColumn( state = lazyListState, modifier = Modifier.fillMaxWidth(), ) { if (searchFilter == null) { searchSummary?.summaries?.forEach { summary -> item { NavigationTitle(summary.title) } items( items = summary.items, key = { "${summary.title}/${it.id}/${summary.items.indexOf(it)}" }, 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 = { "filtered_${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), ) } } } if (searchFilter == null && searchSummary == null || searchFilter != null && itemsPage == null) { item { ShimmerHost { repeat(8) { ListItemPlaceHolder() } } } } item(key = "bottom_spacer") { Spacer(modifier = Modifier.height(MiniPlayerHeight + MiniPlayerBottomSpacing + NavigationBarHeight)) } } } if (isSearchFocused) { OnlineSearchScreen( query = query.text, onQueryChange = { query = it }, navController = navController, onSearch = onSearch, onDismiss = { isSearchFocused = false focusManager.clearFocus() }, pureBlack = pureBlack, ) } HideOnScrollFAB( lazyListState = lazyListState, icon = R.drawable.mic, onClick = { navController.navigate("recognition") }, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/search/OnlineSearchScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.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.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars 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.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.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.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.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource 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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.SuggestionItemHeight import com.metrolist.music.models.toMediaMetadata import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.LocalMenuState import com.metrolist.music.ui.component.YouTubeListItem import com.metrolist.music.ui.menu.YouTubeAlbumMenu import com.metrolist.music.ui.menu.YouTubeArtistMenu import com.metrolist.music.ui.menu.YouTubePlaylistMenu import com.metrolist.music.ui.menu.YouTubeSongMenu import com.metrolist.music.viewmodels.OnlineSearchSuggestionViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.drop @OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class, FlowPreview::class) @Composable fun OnlineSearchScreen( query: String, onQueryChange: (TextFieldValue) -> Unit, navController: NavController, onSearch: (String) -> Unit, onDismiss: () -> Unit, pureBlack: Boolean, viewModel: OnlineSearchSuggestionViewModel = hiltViewModel(), ) { val database = LocalDatabase.current val keyboardController = LocalSoftwareKeyboardController.current val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return val coroutineScope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current val isPlaying by playerConnection.isEffectivelyPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val viewState by viewModel.viewState.collectAsState() val lazyListState = rememberLazyListState() LaunchedEffect(Unit) { snapshotFlow { lazyListState.firstVisibleItemScrollOffset } .drop(1) .collect { keyboardController?.hide() } } LaunchedEffect(query) { snapshotFlow { query }.debounce(300L).collectLatest { viewModel.query.value = it } } LazyColumn( state = lazyListState, contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(), modifier = Modifier .fillMaxSize() .background(if (pureBlack) Color.Black else MaterialTheme.colorScheme.background) ) { items(viewState.history, key = { "history_${it.query}" }) { history -> SuggestionItem( query = history.query, online = false, onClick = { onSearch(history.query) onDismiss() }, onDelete = { database.query { delete(history) } }, onFillTextField = { onQueryChange(TextFieldValue(history.query, TextRange(history.query.length))) }, modifier = Modifier.animateItem(), pureBlack = pureBlack ) } items(viewState.suggestions, key = { "suggestion_$it" }) { query -> SuggestionItem( query = query, online = true, onClick = { onSearch(query) onDismiss() }, onFillTextField = { onQueryChange(TextFieldValue(query, TextRange(query.length))) }, modifier = Modifier.animateItem(), pureBlack = pureBlack ) } if (viewState.items.isNotEmpty() && viewState.history.size + viewState.suggestions.size > 0) { item(key = "search_divider") { HorizontalDivider( modifier = Modifier.animateItem() ) } item(key = "search_divider_spacer") { Spacer(modifier = Modifier.height(8.dp)) } } items(viewState.items, key = { "item_${it.id}" }) { item -> YouTubeListItem( item = item, isActive = when (item) { is SongItem -> mediaMetadata?.id == item.id is AlbumItem -> mediaMetadata?.album?.id == item.id is EpisodeItem -> mediaMetadata?.id == item.id else -> false }, isPlaying = isPlaying, trailingContent = { IconButton( onClick = { menuState.show { when (item) { is SongItem -> YouTubeSongMenu( song = item, navController = navController, onDismiss = { menuState.dismiss() onDismiss() } ) is AlbumItem -> YouTubeAlbumMenu( albumItem = item, navController = navController, onDismiss = { menuState.dismiss() onDismiss() } ) is ArtistItem -> YouTubeArtistMenu( artist = item, onDismiss = { menuState.dismiss() onDismiss() } ) is PlaylistItem -> YouTubePlaylistMenu( playlist = item, coroutineScope = coroutineScope, onDismiss = { menuState.dismiss() onDismiss() } ) is PodcastItem -> YouTubePlaylistMenu( playlist = item.asPlaylistItem(), coroutineScope = coroutineScope, onDismiss = { menuState.dismiss() onDismiss() } ) is EpisodeItem -> YouTubeSongMenu( song = item.asSongItem(), navController = navController, onDismiss = { menuState.dismiss() onDismiss() } ) } } } ) { Icon( painter = painterResource(R.drawable.more_vert), contentDescription = null ) } }, modifier = Modifier .combinedClickable( onClick = { when (item) { is SongItem -> { if (item.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue.radio(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() } is PodcastItem -> { navController.navigate("online_podcast/${item.id}") onDismiss() } is EpisodeItem -> { if (item.id == mediaMetadata?.id) { playerConnection.togglePlayPause() } else { playerConnection.playQueue( YouTubeQueue.radio(item.toMediaMetadata()) ) onDismiss() } } } }, onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) menuState.show { when (item) { is SongItem -> YouTubeSongMenu( song = item, navController = navController, onDismiss = { menuState.dismiss() onDismiss() } ) is AlbumItem -> YouTubeAlbumMenu( albumItem = item, navController = navController, onDismiss = { menuState.dismiss() onDismiss() } ) is ArtistItem -> YouTubeArtistMenu( artist = item, onDismiss = { menuState.dismiss() onDismiss() } ) is PlaylistItem -> YouTubePlaylistMenu( playlist = item, coroutineScope = coroutineScope, onDismiss = { menuState.dismiss() onDismiss() } ) is PodcastItem -> YouTubePlaylistMenu( playlist = item.asPlaylistItem(), coroutineScope = coroutineScope, onDismiss = { menuState.dismiss() onDismiss() } ) is EpisodeItem -> YouTubeSongMenu( song = item.asSongItem(), navController = navController, onDismiss = { menuState.dismiss() onDismiss() } ) } } } ) .background(if (pureBlack) Color.Black else MaterialTheme.colorScheme.surface) .animateItem() ) } } } @Composable fun SuggestionItem( modifier: Modifier = Modifier, query: String, online: Boolean, onClick: () -> Unit, onDelete: () -> Unit = {}, onFillTextField: () -> Unit, pureBlack: Boolean ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .height(SuggestionItemHeight) .background(if (pureBlack) Color.Black else MaterialTheme.colorScheme.surface) .clickable(onClick = onClick) .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)), ) { Icon( painterResource(if (online) R.drawable.search else R.drawable.history), contentDescription = null, modifier = Modifier.padding(horizontal = 16.dp).alpha(0.5f) ) Text( text = query, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) if (!online) { IconButton( onClick = onDelete, modifier = Modifier.alpha(0.5f), ) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, ) } } IconButton( onClick = onFillTextField, modifier = Modifier.alpha(0.5f), ) { Icon( painter = painterResource(R.drawable.arrow_top_left), contentDescription = null, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/search/SearchScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.search import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.lazy.rememberLazyListState 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.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions 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.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalIsPlayerExpanded import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.PauseSearchHistoryKey import com.metrolist.music.constants.SearchSource import com.metrolist.music.constants.SearchSourceKey import com.metrolist.music.db.entities.SearchHistory import com.metrolist.music.ui.component.HideOnScrollFAB import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.net.URLEncoder @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen( navController: NavController, pureBlack: Boolean ) { val database = LocalDatabase.current val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current val isPlayerExpanded = LocalIsPlayerExpanded.current val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current val lazyListState = rememberLazyListState() var searchSource by rememberEnumPreference(SearchSourceKey, SearchSource.ONLINE) var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } val pauseSearchHistory by rememberPreference(PauseSearchHistoryKey, defaultValue = false) var isFirstLaunch by rememberSaveable { mutableStateOf(true) } val onSearch: (String) -> Unit = remember { { searchQuery -> if (searchQuery.isNotEmpty()) { focusManager.clearFocus() navController.navigate("search/${URLEncoder.encode(searchQuery, "UTF-8")}") if (!pauseSearchHistory) { coroutineScope.launch(Dispatchers.IO) { database.query { insert(SearchHistory(query = searchQuery)) } } } } } } val onSearchFromSuggestion: (String) -> Unit = remember { { searchQuery -> if (searchQuery.isNotEmpty()) { focusManager.clearFocus() navController.navigate("search/${URLEncoder.encode(searchQuery, "UTF-8")}") if (!pauseSearchHistory) { coroutineScope.launch(Dispatchers.IO) { database.query { insert(SearchHistory(query = searchQuery)) } } } } } } Scaffold( topBar = { TopAppBar( title = { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { BasicTextField( value = query, onValueChange = { query = it }, modifier = Modifier .weight(1f) .focusRequester(focusRequester), textStyle = TextStyle( color = MaterialTheme.colorScheme.onSurface, fontSize = 16.sp ), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), singleLine = true, decorationBox = { innerTextField -> if (query.text.isEmpty()) { Text( text = stringResource( when (searchSource) { SearchSource.LOCAL -> R.string.search_library SearchSource.ONLINE -> R.string.search_yt_music } ), style = TextStyle( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), fontSize = 16.sp ) ) } innerTextField() }, keyboardOptions = KeyboardOptions( imeAction = ImeAction.Search ), keyboardActions = KeyboardActions( onSearch = { onSearch(query.text) } ) ) Row { if (query.text.isNotEmpty()) { IconButton(onClick = { query = TextFieldValue("") }) { Icon( painter = painterResource(R.drawable.close), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface ) } } 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 = null, tint = MaterialTheme.colorScheme.onSurface ) } } } }, navigationIcon = { IconButton(onClick = { navController.navigateUp() }) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = stringResource(R.string.dismiss), tint = MaterialTheme.colorScheme.onSurface ) } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.surfaceContainer ) ) }, containerColor = if (pureBlack) Color.Black else MaterialTheme.colorScheme.background ) { paddingValues -> val bottomPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding() Box( modifier = Modifier .padding(paddingValues) .fillMaxSize() ) { Box( modifier = Modifier .padding(bottom = bottomPadding) .fillMaxSize() ) { when (searchSource) { SearchSource.LOCAL -> LocalSearchScreen( query = query.text, navController = navController, onDismiss = { navController.navigateUp() }, pureBlack = pureBlack ) SearchSource.ONLINE -> OnlineSearchScreen( query = query.text, onQueryChange = { query = it }, navController = navController, onSearch = onSearchFromSuggestion, onDismiss = { /* Don't dismiss when searching from suggestions */ }, pureBlack = pureBlack ) } } HideOnScrollFAB( lazyListState = lazyListState, icon = R.drawable.mic, onClick = { navController.navigate("recognition") } ) } } // Handle lifecycle events to manage keyboard visibility DisposableEffect(lifecycleOwner, isPlayerExpanded) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_RESUME -> { // Always hide keyboard when resuming if player is expanded if (isPlayerExpanded) { keyboardController?.hide() focusManager.clearFocus() } else if (isFirstLaunch) { // Only request focus on first launch when player is not expanded try { focusRequester.requestFocus() } catch (e: Exception) { // Ignore focus request failures } isFirstLaunch = false } } Lifecycle.Event.ON_PAUSE -> { // Clear focus when pausing to prevent keyboard from showing on resume focusManager.clearFocus() keyboardController?.hide() } else -> {} } } lifecycleOwner.lifecycle.addObserver(observer) // Initial check - hide keyboard if player is expanded if (isPlayerExpanded) { keyboardController?.hide() focusManager.clearFocus() } onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AboutScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings 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.RowScope 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.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler 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.compose.ui.input.nestedscroll.nestedScroll import androidx.graphics.shapes.RoundedPolygon import androidx.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.music.BuildConfig import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.playback.PlayerConnection import com.metrolist.music.playback.queues.YouTubeQueue import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.utils.backToMain import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch private data class Contributor( val name: String, val roleRes: Int, val githubHandle: String, val avatarUrl: String = "https://github.com/$githubHandle.png", val githubUrl: String = "https://github.com/$githubHandle", val polygon: RoundedPolygon? = null, val favoriteSongVideoId: String? = null ) private data class CommunityLink( val labelRes: Int, val iconRes: Int, val url: String ) @OptIn(ExperimentalMaterial3ExpressiveApi::class) private val leadDeveloper = Contributor( name = "Mo Agamy", roleRes = R.string.credits_lead_developer, githubHandle = "mostafaalagamy", polygon = MaterialShapes.Cookie9Sided, favoriteSongVideoId = "Mh2JWGWvy_Y" ) @OptIn(ExperimentalMaterial3ExpressiveApi::class) private val collaborators = listOf( Contributor(name = "Adriel O'Connel", roleRes = R.string.credits_collaborator, githubHandle = "adrielGGmotion", polygon = MaterialShapes.Cookie4Sided, favoriteSongVideoId = "m2zUrruKjDQ"), Contributor(name = "Nyx", roleRes = R.string.credits_collaborator, githubHandle = "nyxiereal", polygon = MaterialShapes.Cookie12Sided, favoriteSongVideoId = "zselaN6zPXw"), // More mass for face ) private val communityLinks = listOf( CommunityLink(R.string.credits_discord, R.drawable.discord, "https://discord.com/invite/zrdbeRG2Mt"), CommunityLink(R.string.credits_telegram, R.drawable.telegram, "https://t.me/metrolistapp"), CommunityLink(R.string.credits_view_repo, R.drawable.github, "https://github.com/MetrolistGroup/Metrolist"), CommunityLink(R.string.credits_license_name, R.drawable.info, "https://github.com/MetrolistGroup/Metrolist/blob/main/LICENSE") ) private fun handleEasterEggClick( clickCount: Int, favoriteSongVideoId: String?, coroutineScope: CoroutineScope, snackbarHostState: SnackbarHostState, playerConnection: PlayerConnection?, wannaPlayStr: String, yeahStr: String, onCountUpdate: (Int) -> Unit ) { if (favoriteSongVideoId != null) { val newCount = clickCount + 1 onCountUpdate(newCount) if (newCount >= 3) { onCountUpdate(0) coroutineScope.launch { val result = snackbarHostState.showSnackbar( message = wannaPlayStr, actionLabel = yeahStr, duration = SnackbarDuration.Short ) if (result == SnackbarResult.ActionPerformed) { playerConnection?.playQueue(YouTubeQueue(WatchEndpoint(videoId = favoriteSongVideoId))) } } } } } @Composable private fun SectionHeader(title: String) { Text( text = title, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) .padding(bottom = 8.dp, top = 8.dp), textAlign = TextAlign.Start ) } @Composable private fun ContributorAvatar( avatarUrl: String, sizeDp: Int, modifier: Modifier = Modifier, shape: Shape = CircleShape, contentDescription: String? = null, onClick: (() -> Unit)? = null ) { val fallback = painterResource(R.drawable.small_icon) Surface( onClick = onClick ?: {}, enabled = onClick != null, modifier = modifier.size(sizeDp.dp), shape = shape, color = MaterialTheme.colorScheme.surfaceContainerHighest, tonalElevation = 4.dp, ) { AsyncImage( model = avatarUrl, contentDescription = contentDescription, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), placeholder = fallback, fallback = fallback, error = fallback, ) } } /** Action button for the 3-segment row under the lead developer */ @Composable private fun RowScope.SegmentedActionButton( label: String, iconRes: Int, iconSize: androidx.compose.ui.unit.Dp = 24.dp, onClick: () -> Unit ) { Surface( onClick = onClick, color = Color.Transparent, modifier = Modifier.weight(1f) ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.height(72.dp) ) { Icon( painter = painterResource(iconRes), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(iconSize) ) Spacer(Modifier.height(4.dp)) Text( text = label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } /** A generic clickable card for secondary actions like Buy Me a Coffee */ @Composable private fun ActionCard( title: String, subtitle: String, iconRes: Int, onClick: () -> Unit, ) { Surface( onClick = onClick, shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(20.dp) ) { Surface( shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.size(48.dp) ) { Box(contentAlignment = Alignment.Center) { androidx.compose.foundation.Image( painter = painterResource(iconRes), contentDescription = null, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer), contentScale = ContentScale.Fit, modifier = Modifier.size(24.dp) ) } } Spacer(Modifier.width(20.dp)) Column { Text( text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Spacer(Modifier.height(2.dp)) Text( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun AboutScreen( navController: NavController, scrollBehavior: TopAppBarScrollBehavior, ) { val uriHandler = LocalUriHandler.current val playerConnection = LocalPlayerConnection.current val coroutineScope = rememberCoroutineScope() val localSnackbarHostState = remember { SnackbarHostState() } val wannaPlayStr = stringResource(R.string.wanna_play_favorite_song) val yeahStr = stringResource(R.string.yeah) Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxWidth() .windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom ) ) .verticalScroll(rememberScrollState()) .nestedScroll(scrollBehavior.nestedScrollConnection), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top) ) ) Spacer(Modifier.height(16.dp)) Surface( shape = RoundedCornerShape(32.dp), color = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(vertical = 32.dp) ) { Box( modifier = Modifier .size(80.dp) .clip(MaterialShapes.SoftBurst.toShape()) .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center ) { androidx.compose.foundation.Image( painter = painterResource(R.drawable.small_icon), contentDescription = stringResource(R.string.metrolist), colorFilter = ColorFilter.tint( color = MaterialTheme.colorScheme.onPrimaryContainer, blendMode = BlendMode.SrcIn, ), modifier = Modifier.size(40.dp) ) } Spacer(Modifier.height(16.dp)) Text( text = stringResource(R.string.metrolist), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onSurface, letterSpacing = MaterialTheme.typography.headlineMedium.letterSpacing ) Spacer(Modifier.height(8.dp)) Surface( shape = CircleShape, color = MaterialTheme.colorScheme.secondaryContainer ) { val archText = BuildConfig.ARCHITECTURE.uppercase() val versionText = if (BuildConfig.DEBUG) { stringResource(R.string.app_version_info, BuildConfig.VERSION_NAME, "$archText • DEBUG") } else { stringResource(R.string.app_version_info, BuildConfig.VERSION_NAME, archText) } Text( text = versionText, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) ) } } } Spacer(Modifier.height(32.dp)) LinearWavyProgressIndicator( progress = { 1f }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 48.dp), color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.5f), trackColor = Color.Transparent, amplitude = { 1f } ) Spacer(Modifier.height(32.dp)) SectionHeader(stringResource(R.string.credits_lead_developer)) var leadClickCount by remember(leadDeveloper.name) { mutableIntStateOf(0) } // Large Avatar ContributorAvatar( avatarUrl = leadDeveloper.avatarUrl, sizeDp = 180, shape = leadDeveloper.polygon?.toShape() ?: CircleShape, contentDescription = leadDeveloper.name, onClick = { handleEasterEggClick( clickCount = leadClickCount, favoriteSongVideoId = leadDeveloper.favoriteSongVideoId, coroutineScope = coroutineScope, snackbarHostState = localSnackbarHostState, playerConnection = playerConnection, wannaPlayStr = wannaPlayStr, yeahStr = yeahStr, onCountUpdate = { leadClickCount = it } ) } ) Spacer(Modifier.height(24.dp)) Text( text = leadDeveloper.name, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, ) Spacer(Modifier.height(32.dp)) // Segmented buttons (Website, GitHub, Instagram) Surface( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) ) { Row(modifier = Modifier.fillMaxWidth()) { SegmentedActionButton( label = stringResource(R.string.credits_website), iconRes = R.drawable.language, iconSize = 24.dp, onClick = { uriHandler.openUri("https://metrolist.meowery.eu") } ) Box(modifier = Modifier.width(1.dp).height(72.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha=0.5f))) SegmentedActionButton( label = stringResource(R.string.credits_github), iconRes = R.drawable.github, iconSize = 24.dp, onClick = { uriHandler.openUri("https://github.com/mostafaalagamy") } ) Box(modifier = Modifier.width(1.dp).height(72.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha=0.5f))) SegmentedActionButton( label = stringResource(R.string.credits_instagram), iconRes = R.drawable.instagram, iconSize = 20.dp, onClick = { uriHandler.openUri("https://www.instagram.com/mostafaalagamy") } ) } } Spacer(Modifier.height(16.dp)) ActionCard( title = stringResource(R.string.like_what_i_do), subtitle = stringResource(R.string.buy_mo_a_coffee), iconRes = R.drawable.buymeacoffee, onClick = { uriHandler.openUri("https://buymeacoffee.com/mostafaalagamy") } ) Spacer(Modifier.height(48.dp)) SectionHeader(stringResource(R.string.credits_collaborators_section)) Surface( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) ) { Column(modifier = Modifier.padding(vertical = 8.dp)) { collaborators.forEachIndexed { index, contributor -> var clickCount by remember(contributor.name) { mutableIntStateOf(0) } ListItem( headlineContent = { Text( text = contributor.name, fontWeight = FontWeight.SemiBold ) }, supportingContent = { Text(stringResource(contributor.roleRes)) }, leadingContent = { ContributorAvatar( avatarUrl = contributor.avatarUrl, sizeDp = 56, shape = contributor.polygon?.toShape() ?: CircleShape, contentDescription = contributor.name, onClick = { handleEasterEggClick( clickCount = clickCount, favoriteSongVideoId = contributor.favoriteSongVideoId, coroutineScope = coroutineScope, snackbarHostState = localSnackbarHostState, playerConnection = playerConnection, wannaPlayStr = wannaPlayStr, yeahStr = yeahStr, onCountUpdate = { clickCount = it } ) } ) }, trailingContent = { Icon( painter = painterResource(R.drawable.github), contentDescription = stringResource(R.string.credits_github), modifier = Modifier.size(20.dp) ) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier.clickable { uriHandler.openUri(contributor.githubUrl) } ) if (index < collaborators.lastIndex) { HorizontalDivider( modifier = Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) ) } } } } Spacer(Modifier.height(32.dp)) SectionHeader(stringResource(R.string.community_and_info)) Surface( shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) ) { Column(modifier = Modifier.padding(vertical = 8.dp)) { communityLinks.forEachIndexed { index, link -> ListItem( headlineContent = { Text(stringResource(link.labelRes), fontWeight = FontWeight.SemiBold) }, supportingContent = if (link.labelRes == R.string.credits_license_name) { { Text(stringResource(R.string.credits_license_desc)) } } else null, leadingContent = { Icon(painterResource(link.iconRes), null, modifier = Modifier.size(24.dp)) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = Modifier.clickable { uriHandler.openUri(link.url) } ) if (index < communityLinks.lastIndex) { HorizontalDivider( modifier = Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) ) } } } } Spacer(Modifier.height(32.dp)) Text( text = stringResource(R.string.stands_with_palestine), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(40.dp)) } TopAppBar( title = { Text(stringResource(R.string.about)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = stringResource(R.string.cd_back), ) } }, scrollBehavior = scrollBehavior, ) androidx.compose.material3.SnackbarHost( hostState = localSnackbarHostState, modifier = Modifier .align(Alignment.BottomCenter) .windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) ) ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AccountSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings 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.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.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults 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.rememberCoroutineScope import androidx.compose.runtime.setValue import kotlinx.coroutines.launch import timber.log.Timber 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.platform.LocalUriHandler 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.TextFieldValue import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.innertube.YouTube import com.metrolist.innertube.utils.parseCookieString import com.metrolist.music.BuildConfig import com.metrolist.music.R import com.metrolist.music.constants.AccountChannelHandleKey import com.metrolist.music.constants.AccountEmailKey import com.metrolist.music.constants.AccountNameKey import com.metrolist.music.constants.DataSyncIdKey import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.UseLoginForBrowse import com.metrolist.music.constants.VisitorDataKey import com.metrolist.music.constants.YtmSyncKey import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.InfoLabel import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.component.PreferenceEntry import com.metrolist.music.ui.component.TextFieldDialog import com.metrolist.music.utils.Updater import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.AccountSettingsViewModel import com.metrolist.music.viewmodels.HomeViewModel @Composable fun AccountSettings( navController: NavController, onClose: () -> Unit, latestVersionName: String ) { val context = LocalContext.current val uriHandler = LocalUriHandler.current val (accountNamePref, 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) { "SAPISID" in parseCookieString(innerTubeCookie) } val (useLoginForBrowse, onUseLoginForBrowseChange) = rememberPreference(UseLoginForBrowse, true) val (ytmSync, onYtmSyncChange) = rememberPreference(YtmSyncKey, true) val homeViewModel: HomeViewModel = hiltViewModel() val accountSettingsViewModel: AccountSettingsViewModel = hiltViewModel() val accountName by homeViewModel.accountName.collectAsState() val accountImageUrl by homeViewModel.accountImageUrl.collectAsState() var showToken by remember { mutableStateOf(false) } var showTokenEditor by remember { mutableStateOf(false) } var showLogoutDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() Column( modifier = Modifier .background(MaterialTheme.colorScheme.surfaceContainer) .padding(16.dp) .verticalScroll(rememberScrollState()) ) { Row( modifier = Modifier .fillMaxWidth() .padding(start = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(id = R.string.app_name), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.padding(start = 4.dp) ) Spacer(modifier = Modifier.weight(1f)) IconButton(onClick = onClose) { Icon(painterResource(R.drawable.close), contentDescription = null) } } Spacer(Modifier.height(12.dp)) // Logout confirmation dialog if (showLogoutDialog) { DefaultDialog( onDismiss = { showLogoutDialog = false }, title = { Text(stringResource(R.string.logout_dialog_title)) }, content = { Text( text = stringResource(R.string.logout_dialog_message), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(horizontal = 18.dp) ) }, buttons = { TextButton( onClick = { Timber.d("[LOGOUT_CLEAR] User chose to clear data") scope.launch { Timber.d("[LOGOUT_CLEAR] Starting clear and logout process") accountSettingsViewModel.clearAllLibraryData() Timber.d("[LOGOUT_CLEAR] Library data cleared, now logging out") accountSettingsViewModel.logoutKeepData(context, onInnerTubeCookieChange) Timber.d("[LOGOUT_CLEAR] Logout complete") showLogoutDialog = false onClose() } } ) { Text(stringResource(R.string.logout_clear)) } TextButton( onClick = { Timber.d("[LOGOUT_KEEP] User chose to keep data") scope.launch { Timber.d("[LOGOUT_KEEP] Starting logout process (keeping data)") accountSettingsViewModel.logoutKeepData(context, onInnerTubeCookieChange) Timber.d("[LOGOUT_KEEP] Logout complete") showLogoutDialog = false onClose() } } ) { Text(stringResource(R.string.logout_keep)) } } ) } if (showTokenEditor) { val text = """ ***INNERTUBE COOKIE*** =$innerTubeCookie ***VISITOR DATA*** =$visitorData ***DATASYNC ID*** =$dataSyncId ***ACCOUNT NAME*** =$accountNamePref ***ACCOUNT EMAIL*** =$accountEmail ***ACCOUNT CHANNEL HANDLE*** =$accountChannelHandle """.trimIndent() TextFieldDialog( initialTextFieldValue = TextFieldValue(text), onDone = { data -> var cookie = "" var visitorDataValue = "" var dataSyncIdValue = "" var accountNameValue = "" var accountEmailValue = "" var accountChannelHandleValue = "" data.split("\n").forEach { when { it.startsWith("***INNERTUBE COOKIE*** =") -> cookie = it.substringAfter("=") it.startsWith("***VISITOR DATA*** =") -> visitorDataValue = it.substringAfter("=") it.startsWith("***DATASYNC ID*** =") -> dataSyncIdValue = it.substringAfter("=") it.startsWith("***ACCOUNT NAME*** =") -> accountNameValue = it.substringAfter("=") it.startsWith("***ACCOUNT EMAIL*** =") -> accountEmailValue = it.substringAfter("=") it.startsWith("***ACCOUNT CHANNEL HANDLE*** =") -> accountChannelHandleValue = it.substringAfter("=") } } // Write all credentials atomically to DataStore and wait for completion // before restarting, preventing the race condition where the process // would be killed before async DataStore coroutines finished writing. accountSettingsViewModel.saveTokenAndRestart( context = context, cookie = cookie, visitorData = visitorDataValue, dataSyncId = dataSyncIdValue, accountName = accountNameValue, accountEmail = accountEmailValue, accountChannelHandle = accountChannelHandleValue, ) }, onDismiss = { showTokenEditor = false }, singleLine = false, maxLines = 20, isInputValid = { fullText -> // Extract the cookie value from the formatted template line, // then validate it separately — avoids the bug where parseCookieString // received the entire multi-line template and failed to find "SAPISID" // as a key because the "***INNERTUBE COOKIE*** =" prefix shadowed it. val cookieLine = fullText.lines() .find { it.startsWith("***INNERTUBE COOKIE*** =") } val cookieValue = cookieLine?.substringAfter("***INNERTUBE COOKIE*** =")?.trim() ?: "" cookieValue.isNotEmpty() && "SAPISID" in parseCookieString(cookieValue) }, extraContent = { Spacer(Modifier.height(8.dp)) InfoLabel(text = stringResource(R.string.token_adv_login_description)) } ) } Material3SettingsGroup( items = listOf( Material3SettingsItem( title = { Row( verticalAlignment = Alignment.CenterVertically ) { if (isLoggedIn && accountImageUrl != null) { AsyncImage( model = accountImageUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(40.dp).clip(CircleShape) ) Spacer(Modifier.width(12.dp)) } Text( text = if (isLoggedIn) accountName else stringResource(R.string.login), ) } }, icon = if (!isLoggedIn) painterResource(R.drawable.login) else null, trailingContent = { if (isLoggedIn) { OutlinedButton( onClick = { Timber.d("[LOGOUT] User clicked logout button, showing dialog") showLogoutDialog = true }, colors = ButtonDefaults.outlinedButtonColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, contentColor = MaterialTheme.colorScheme.onSurface ) ) { Text(stringResource(R.string.action_logout)) } } }, onClick = { onClose() if (isLoggedIn) { navController.navigate("account") } else { navController.navigate("login") } } ) ), useLowContrast = true ) Spacer(Modifier.height(8.dp)) Material3SettingsGroup( items = listOf( Material3SettingsItem( title = { Text( when { !isLoggedIn -> stringResource(R.string.advanced_login) showToken -> stringResource(R.string.token_shown) else -> stringResource(R.string.token_hidden) } ) }, icon = painterResource(R.drawable.token), onClick = { if (!isLoggedIn) showTokenEditor = true else if (!showToken) showToken = true else showTokenEditor = true } ), Material3SettingsItem( title = { Text(stringResource(R.string.more_content)) }, icon = painterResource(R.drawable.cached), trailingContent = { Switch( enabled = isLoggedIn, checked = useLoginForBrowse, onCheckedChange = { YouTube.useLoginForBrowse = it onUseLoginForBrowseChange(it) }, thumbContent = { Icon( painter = painterResource( id = if (useLoginForBrowse) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, enabled = isLoggedIn ), Material3SettingsItem( title = { Text(stringResource(R.string.yt_sync)) }, icon = painterResource(R.drawable.cached), trailingContent = { Switch( enabled = isLoggedIn, checked = ytmSync, onCheckedChange = onYtmSyncChange, thumbContent = { Icon( painter = painterResource( id = if (ytmSync) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, enabled = isLoggedIn ) ), useLowContrast = true ) Spacer(Modifier.height(12.dp)) Column( modifier = Modifier .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.surfaceContainer) ) { PreferenceEntry( title = { Text(stringResource(R.string.integrations)) }, icon = { Icon(painterResource(R.drawable.integration), null) }, onClick = { onClose() navController.navigate("settings/integrations") }, modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceContainer) ) Spacer(Modifier.height(4.dp)) PreferenceEntry( title = { Text(stringResource(R.string.settings)) }, icon = { BadgedBox( badge = { if (BuildConfig.UPDATER_AVAILABLE && latestVersionName != BuildConfig.VERSION_NAME) { Badge() } } ) { Icon(painterResource(R.drawable.settings), contentDescription = null) } }, onClick = { onClose() navController.navigate("settings") }, modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceContainer) ) Spacer(Modifier.height(4.dp)) if (BuildConfig.UPDATER_AVAILABLE && latestVersionName != BuildConfig.VERSION_NAME) { val releaseInfo = Updater.getCachedLatestRelease() val downloadUrl = releaseInfo?.let { Updater.getDownloadUrlForCurrentVariant(it) } if (downloadUrl != null) { PreferenceEntry( title = { Text(text = stringResource(R.string.new_version_available)) }, description = latestVersionName, icon = { BadgedBox(badge = { Badge() }) { Icon(painterResource(R.drawable.update), null) } }, onClick = { uriHandler.openUri(downloadUrl) } ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AiSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.size 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.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.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.AiProviderKey import com.metrolist.music.constants.AiSystemPromptKey import com.metrolist.music.constants.DEFAULT_AI_SYSTEM_PROMPT import com.metrolist.music.constants.DeeplApiKey import com.metrolist.music.constants.DeeplFormalityKey import com.metrolist.music.constants.LanguageCodeToName import com.metrolist.music.constants.OpenRouterApiKey import com.metrolist.music.constants.OpenRouterBaseUrlKey import com.metrolist.music.constants.OpenRouterModelKey import com.metrolist.music.constants.TranslateLanguageKey import com.metrolist.music.constants.TranslateModeKey import com.metrolist.music.ui.component.EnumDialog import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.component.TextFieldDialog import com.metrolist.music.utils.rememberPreference @OptIn(ExperimentalMaterial3Api::class) @Composable fun AiSettings(navController: NavController) { var aiProvider by rememberPreference(AiProviderKey, "OpenRouter") var openRouterApiKey by rememberPreference(OpenRouterApiKey, "") var openRouterBaseUrl by rememberPreference(OpenRouterBaseUrlKey, "https://openrouter.ai/api/v1/chat/completions") var openRouterModel by rememberPreference(OpenRouterModelKey, "google/gemini-2.5-flash-lite") var translateLanguage by rememberPreference(TranslateLanguageKey, "en") var translateMode by rememberPreference(TranslateModeKey, "Literal") var deeplApiKey by rememberPreference(DeeplApiKey, "") var deeplFormality by rememberPreference(DeeplFormalityKey, "default") var aiSystemPrompt by rememberPreference(AiSystemPromptKey, "") val aiProviders = mapOf( "OpenRouter" to "https://openrouter.ai/api/v1/chat/completions", "OpenAI" to "https://api.openai.com/v1/chat/completions", "Perplexity" to "https://api.perplexity.ai/chat/completions", "Claude" to "https://api.anthropic.com/v1/messages", "Gemini" to "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", "XAi" to "https://api.x.ai/v1/chat/completions", "Mistral" to "https://api.mistral.ai/v1/chat/completions", "DeepL" to "https://api.deepl.com/v2/translate", "Custom" to "", ) val providerHelpText = mapOf( "OpenRouter" to stringResource(R.string.ai_provider_openrouter_help), "OpenAI" to stringResource(R.string.ai_provider_openai_help), "Perplexity" to stringResource(R.string.ai_provider_perplexity_help), "Claude" to stringResource(R.string.ai_provider_claude_help), "Gemini" to stringResource(R.string.ai_provider_gemini_help), "XAi" to stringResource(R.string.ai_provider_xai_help), "Mistral" to stringResource(R.string.ai_provider_mistral_help), "DeepL" to stringResource(R.string.ai_provider_deepl_help), "Custom" to "", ) val modelsByProvider = mapOf( "OpenRouter" to listOf( "google/gemini-2.5-flash-lite", "google/gemini-2.5-flash", "x-ai/grok-4.1-fast", "deepseek/deepseek-v3.1-terminus:exacto", "openai/gpt-4o-mini", "meta-llama/llama-4-scout", "openai/gpt-5-nano", "openai/gpt-oss-120b", "google/gemini-3-flash-preview", ), "OpenAI" to listOf( "gpt-4o-mini", "gpt-4o", "gpt-4-turbo", ), "Claude" to listOf( "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001", ), "Gemini" to listOf( "gemini-flash-lite-latest", "gemini-2.5-flash-lite", "gemini-flash-latest", "gemini-2.5-flash", "gemini-3-flash-preview", ), "Perplexity" to listOf( "sonar", "sonar-pro", "sonar-reasoning", ), "XAi" to listOf( "grok-4-1-fast", "grok-vision-beta", ), "Mistral" to listOf( "mistral-large-latest", "mistral-medium-latest", "mistral-small-latest", "mistral-tiny-latest", ), "DeepL" to listOf(), "Custom" to listOf(), ) val commonModels = modelsByProvider[aiProvider] ?: listOf() var showProviderDialog by rememberSaveable { mutableStateOf(false) } var showProviderHelpDialog by rememberSaveable { mutableStateOf(false) } var showTranslateModeDialog by rememberSaveable { mutableStateOf(false) } var showTranslateModeHelpDialog by rememberSaveable { mutableStateOf(false) } var showLanguageDialog by rememberSaveable { mutableStateOf(false) } var showApiKeyDialog by rememberSaveable { mutableStateOf(false) } var showDeeplApiKeyDialog by rememberSaveable { mutableStateOf(false) } var showDeeplFormalityDialog by rememberSaveable { mutableStateOf(false) } var showBaseUrlDialog by rememberSaveable { mutableStateOf(false) } var showModelDialog by rememberSaveable { mutableStateOf(false) } var showCustomModelInput by rememberSaveable { mutableStateOf(false) } var showSystemPromptDialog by rememberSaveable { mutableStateOf(false) } if (showProviderHelpDialog) { AlertDialog( onDismissRequest = { showProviderHelpDialog = false }, confirmButton = { TextButton(onClick = { showProviderHelpDialog = false }) { Text(stringResource(android.R.string.ok)) } }, icon = { Icon(painterResource(R.drawable.info), null) }, title = { Text(stringResource(R.string.ai_provider_help)) }, text = { Column { providerHelpText.forEach { (provider, help) -> if (help.isNotEmpty()) { val primaryColor = MaterialTheme.colorScheme.primary val annotatedString = buildAnnotatedString { append("$provider: ") // Extract URL from text val urlRegex = "https?://[^\\s]+".toRegex() val match = urlRegex.find(help) if (match != null) { val url = match.value val beforeUrl = help.substring(0, match.range.first) val afterUrl = help.substring(match.range.last + 1) append(beforeUrl) val linkStart = length append(url) val linkEnd = length append(afterUrl) addLink( LinkAnnotation.Url( url = url, styles = TextLinkStyles( style = SpanStyle( color = primaryColor, textDecoration = TextDecoration.Underline, ), ), ), start = linkStart, end = linkEnd, ) } else { append(help) } } Text( text = annotatedString, style = MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onSurface, ), modifier = Modifier.padding(vertical = 4.dp), ) } } } }, ) } if (showTranslateModeHelpDialog) { AlertDialog( onDismissRequest = { showTranslateModeHelpDialog = false }, confirmButton = { TextButton(onClick = { showTranslateModeHelpDialog = false }) { Text(stringResource(android.R.string.ok)) } }, icon = { Icon(painterResource(R.drawable.info), null) }, title = { Text(stringResource(R.string.ai_translation_mode)) }, text = { Column { Text( text = "${stringResource(R.string.ai_translation_literal)}:", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 8.dp), ) Text( text = stringResource(R.string.ai_translation_literal_desc), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 12.dp), ) Text( text = "${stringResource(R.string.ai_translation_transcribed)}:", style = MaterialTheme.typography.titleSmall, ) Text( text = stringResource(R.string.ai_translation_transcribed_desc), style = MaterialTheme.typography.bodyMedium, ) } }, ) } if (showProviderDialog) { EnumDialog( onDismiss = { showProviderDialog = false }, onSelect = { aiProvider = it if (it != "Custom" && it != "DeepL") { openRouterBaseUrl = aiProviders[it] ?: "" } else { openRouterBaseUrl = "" } // Set model to first available model for the selected provider val modelsForProvider = modelsByProvider[it] ?: listOf() openRouterModel = if (modelsForProvider.isNotEmpty()) { modelsForProvider[0] } else { "" } showProviderDialog = false }, title = stringResource(R.string.ai_provider), current = aiProvider, values = aiProviders.keys.toList(), valueText = { it }, ) } if (showTranslateModeDialog) { EnumDialog( onDismiss = { showTranslateModeDialog = false }, onSelect = { translateMode = it showTranslateModeDialog = false }, title = stringResource(R.string.ai_translation_mode), current = translateMode, values = listOf("Literal", "Transcribed"), valueText = { when (it) { "Literal" -> stringResource(R.string.ai_translation_literal) "Transcribed" -> stringResource(R.string.ai_translation_transcribed) else -> it } }, ) } if (showLanguageDialog) { EnumDialog( onDismiss = { showLanguageDialog = false }, onSelect = { translateLanguage = it showLanguageDialog = false }, title = stringResource(R.string.ai_target_language), current = translateLanguage, values = LanguageCodeToName.keys.sortedBy { LanguageCodeToName[it] }, valueText = { LanguageCodeToName[it] ?: it }, ) } if (showApiKeyDialog) { TextFieldDialog( title = { Text(stringResource(R.string.ai_api_key)) }, icon = { Icon(painterResource(R.drawable.key), null) }, initialTextFieldValue = TextFieldValue(text = openRouterApiKey), onDone = { openRouterApiKey = it showApiKeyDialog = false }, onDismiss = { showApiKeyDialog = false }, ) } if (showDeeplApiKeyDialog) { TextFieldDialog( title = { Text("DeepL ${stringResource(R.string.ai_api_key)}") }, icon = { Icon(painterResource(R.drawable.key), null) }, initialTextFieldValue = TextFieldValue(text = deeplApiKey), onDone = { deeplApiKey = it showDeeplApiKeyDialog = false }, onDismiss = { showDeeplApiKeyDialog = false }, ) } if (showDeeplFormalityDialog) { EnumDialog( onDismiss = { showDeeplFormalityDialog = false }, onSelect = { deeplFormality = it showDeeplFormalityDialog = false }, title = stringResource(R.string.ai_deepl_formality), current = deeplFormality, values = listOf("default", "more", "less"), valueText = { when (it) { "default" -> stringResource(R.string.ai_deepl_formality_default) "more" -> stringResource(R.string.ai_deepl_formality_more) "less" -> stringResource(R.string.ai_deepl_formality_less) else -> it } }, ) } if (showBaseUrlDialog && aiProvider == "Custom") { TextFieldDialog( title = { Text(stringResource(R.string.ai_base_url)) }, icon = { Icon(painterResource(R.drawable.link), null) }, initialTextFieldValue = TextFieldValue(text = openRouterBaseUrl), onDone = { openRouterBaseUrl = it showBaseUrlDialog = false }, onDismiss = { showBaseUrlDialog = false }, ) } if (showModelDialog) { EnumDialog( onDismiss = { showModelDialog = false }, onSelect = { if (it == "custom_input") { showCustomModelInput = true showModelDialog = false } else { openRouterModel = it showModelDialog = false } }, title = stringResource(R.string.ai_model), current = if (openRouterModel in commonModels) openRouterModel else "custom_input", values = commonModels + "custom_input", valueText = { if (it == "custom_input") "Custom" else it }, ) } if (showCustomModelInput) { TextFieldDialog( title = { Text(stringResource(R.string.ai_model)) }, icon = { Icon(painterResource(R.drawable.discover_tune), null) }, initialTextFieldValue = TextFieldValue(text = openRouterModel), onDone = { openRouterModel = it showCustomModelInput = false }, onDismiss = { showCustomModelInput = false }, ) } if (showSystemPromptDialog) { TextFieldDialog( title = { Text(stringResource(R.string.ai_system_prompt)) }, icon = { Icon(painterResource(R.drawable.edit), null) }, initialTextFieldValue = TextFieldValue(text = aiSystemPrompt.ifBlank { DEFAULT_AI_SYSTEM_PROMPT }), singleLine = false, maxLines = 12, isInputValid = { true }, onDone = { // Treat saving the unmodified default (or blank) as "use default" aiSystemPrompt = if (it.isBlank() || it == DEFAULT_AI_SYSTEM_PROMPT) "" else it showSystemPromptDialog = false }, onDismiss = { showSystemPromptDialog = false }, extraContent = { if (aiSystemPrompt.isNotBlank()) { Row( modifier = Modifier .fillMaxWidth() .padding(top = 8.dp), horizontalArrangement = Arrangement.End, ) { TextButton( onClick = { aiSystemPrompt = "" showSystemPromptDialog = false }, ) { Text(stringResource(R.string.ai_system_prompt_reset)) } } } }, ) } Column( Modifier .windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, ), ).verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Top, ), ), ) Material3SettingsGroup( title = stringResource(R.string.ai_provider), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.explore_outlined), title = { Text(stringResource(R.string.ai_provider)) }, description = { Text(aiProvider) }, onClick = { showProviderDialog = true }, trailingContent = { IconButton(onClick = { showProviderHelpDialog = true }) { Icon( painterResource(R.drawable.info), contentDescription = stringResource(R.string.ai_provider_help), modifier = Modifier.size(20.dp), ) } }, ), if (aiProvider == "Custom") { Material3SettingsItem( icon = painterResource(R.drawable.link), title = { Text(stringResource(R.string.ai_base_url)) }, description = { Text(openRouterBaseUrl.ifBlank { stringResource(R.string.not_set) }) }, onClick = { showBaseUrlDialog = true }, ) } else { null }, ).filterNotNull(), ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.ai_setup_guide), items = buildList { if (aiProvider == "DeepL") { add( Material3SettingsItem( icon = painterResource(R.drawable.key), title = { Text("DeepL ${stringResource(R.string.ai_api_key)}") }, description = { Text( if (deeplApiKey.isNotEmpty()) { "•".repeat(minOf(deeplApiKey.length, 8)) } else { stringResource(R.string.not_set) }, ) }, onClick = { showDeeplApiKeyDialog = true }, ), ) add( Material3SettingsItem( icon = painterResource(R.drawable.tune), title = { Text(stringResource(R.string.ai_deepl_formality)) }, description = { Text( when (deeplFormality) { "default" -> stringResource(R.string.ai_deepl_formality_default) "more" -> stringResource(R.string.ai_deepl_formality_more) "less" -> stringResource(R.string.ai_deepl_formality_less) else -> deeplFormality }, ) }, onClick = { showDeeplFormalityDialog = true }, ), ) } else { add( Material3SettingsItem( icon = painterResource(R.drawable.key), title = { Text(stringResource(R.string.ai_api_key)) }, description = { Text( if (openRouterApiKey.isNotEmpty()) { "•".repeat(minOf(openRouterApiKey.length, 8)) } else { stringResource(R.string.not_set) }, ) }, onClick = { showApiKeyDialog = true }, ), ) add( Material3SettingsItem( icon = painterResource(R.drawable.discover_tune), title = { Text(stringResource(R.string.ai_model)) }, description = { Text(openRouterModel.ifBlank { stringResource(R.string.not_set) }) }, onClick = { showModelDialog = true }, ), ) } }, ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.ai_translation_mode), items = buildList { if (aiProvider != "DeepL") { add( Material3SettingsItem( icon = painterResource(R.drawable.translate), title = { Text(stringResource(R.string.ai_translation_mode)) }, description = { Text( when (translateMode) { "Literal" -> stringResource(R.string.ai_translation_literal) "Transcribed" -> stringResource(R.string.ai_translation_transcribed) else -> translateMode }, ) }, onClick = { showTranslateModeDialog = true }, trailingContent = { IconButton(onClick = { showTranslateModeHelpDialog = true }) { Icon( painterResource(R.drawable.info), contentDescription = null, modifier = Modifier.size(20.dp), ) } }, ), ) add( Material3SettingsItem( icon = painterResource(R.drawable.edit), title = { Text(stringResource(R.string.ai_system_prompt)) }, description = { Text( if (aiSystemPrompt.isNotBlank()) { aiSystemPrompt.take(60).let { if (aiSystemPrompt.length > 60) "$it…" else it } } else { stringResource(R.string.ai_system_prompt_default) }, ) }, onClick = { showSystemPromptDialog = true }, ), ) } add( Material3SettingsItem( icon = painterResource(R.drawable.language), title = { Text(stringResource(R.string.ai_target_language)) }, description = { Text(LanguageCodeToName[translateLanguage] ?: translateLanguage) }, onClick = { showLanguageDialog = true }, ), ) }, ) Spacer(modifier = Modifier.height(16.dp)) } TopAppBar( title = { Text(stringResource(R.string.ai_lyrics_translation)) }, navigationIcon = { IconButton(onClick = { navController.navigateUp() }) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AlarmSettings.kt ================================================ package com.metrolist.music.ui.screens.settings import android.app.AlarmManager import android.content.ActivityNotFoundException import android.content.Intent import android.os.Build import android.os.PowerManager import android.provider.Settings import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.metrolist.music.LocalDatabase import com.metrolist.music.R import com.metrolist.music.db.entities.Playlist import com.metrolist.music.playback.alarm.MusicAlarmEntry import com.metrolist.music.playback.alarm.MusicAlarmScheduler import com.metrolist.music.playback.alarm.MusicAlarmStore import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun AlarmSettingsSection(showTitle: Boolean = true) { val context = LocalContext.current val locale = LocalLocale.current.platformLocale val database = LocalDatabase.current val scope = rememberCoroutineScope() val playlists by database.playlistsByNameAsc().collectAsState(initial = emptyList()) val persistMutex = remember { Mutex() } val selectPlaylistText = stringResource(R.string.alarm_select_playlist) val randomEnabledText = stringResource(R.string.alarm_random_enabled) val randomDisabledText = stringResource(R.string.alarm_random_disabled) val notScheduledText = stringResource(R.string.alarm_not_scheduled) var alarms by remember { mutableStateOf(emptyList()) } var showEditor by remember { mutableStateOf(false) } var editorTarget by remember { mutableStateOf(null) } val alarmManager = context.getSystemService(AlarmManager::class.java) val canScheduleExact = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { alarmManager?.canScheduleExactAlarms() == true } else { true } val powerManager = context.getSystemService(PowerManager::class.java) val ignoringBatteryOptimization = powerManager?.isIgnoringBatteryOptimizations(context.packageName) == true val systemItems = buildList { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !canScheduleExact) { add( Material3SettingsItem( icon = painterResource(R.drawable.warning), title = { Text(stringResource(R.string.alarm_exact_permission_title)) }, description = { Text(stringResource(R.string.alarm_exact_permission_desc)) }, onClick = { try { val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) .setData("package:${context.packageName}".toUri()) context.startActivity(intent) } catch (_: ActivityNotFoundException) { } } ) ) } if (!ignoringBatteryOptimization) { add( Material3SettingsItem( icon = painterResource(R.drawable.warning), title = { Text(stringResource(R.string.alarm_battery_optimization_title)) }, description = { Text(stringResource(R.string.alarm_battery_optimization_desc)) }, onClick = { try { context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) } catch (_: ActivityNotFoundException) { } } ) ) } } suspend fun loadAlarms(): List { return withContext(Dispatchers.IO) { MusicAlarmStore.load(context) }.sortedBy { it.hour * 60 + it.minute } } fun persistAndSchedule(transform: (List) -> List) { scope.launch { persistMutex.withLock { val latest = loadAlarms() val newList = transform(latest) withContext(Dispatchers.IO) { MusicAlarmScheduler.scheduleAll(context, newList) } alarms = loadAlarms() } } } androidx.compose.runtime.LaunchedEffect(Unit) { alarms = loadAlarms() } if (showEditor) { AlarmEditorDialog( existing = editorTarget, allAlarms = alarms, playlists = playlists, onDismiss = { showEditor = false editorTarget = null }, onSave = { updated -> persistAndSchedule { current -> current.filterNot { it.id == updated.id } + updated } showEditor = false editorTarget = null } ) } Material3SettingsGroup( title = if (showTitle) stringResource(R.string.alarm) else null, items = buildList { add( Material3SettingsItem( icon = painterResource(R.drawable.add_circle), title = { Text(stringResource(R.string.alarm_add)) }, onClick = { editorTarget = null showEditor = true } ) ) if (alarms.isEmpty()) { add( Material3SettingsItem( icon = painterResource(R.drawable.bedtime), title = { Text(stringResource(R.string.alarm_empty)) } ) ) } else { addAll( alarms.map { alarm -> val playlistTitle = playlists.firstOrNull { it.id == alarm.playlistId }?.title ?: selectPlaylistText val triggerText = if (alarm.nextTriggerAt > 0L) { DateTimeFormatter.ofPattern("EEE, HH:mm", locale) .format( Instant.ofEpochMilli(alarm.nextTriggerAt) .atZone(ZoneId.systemDefault()) ) } else { notScheduledText } val description = buildString { append(playlistTitle) append(" • ") append(if (alarm.randomSong) randomEnabledText else randomDisabledText) append("\n") append(stringResource(R.string.alarm_next_prefix, triggerText)) } Material3SettingsItem( icon = painterResource(R.drawable.bedtime), title = { Text( String.format(locale, "%02d:%02d", alarm.hour, alarm.minute) + if (alarm.enabled) { "" } else { " (${stringResource(R.string.alarm_disabled)})" } ) }, description = { Text(description) }, trailingContent = { Row(verticalAlignment = Alignment.CenterVertically) { AlarmSwitch( checked = alarm.enabled, onCheckedChange = { enabled -> persistAndSchedule { current -> current.map { if (it.id == alarm.id) it.copy(enabled = enabled) else it } } } ) IconButton( onClick = { persistAndSchedule { current -> current.filterNot { it.id == alarm.id } } } ) { Icon( painter = painterResource(R.drawable.delete), contentDescription = stringResource(R.string.alarm_delete) ) } } }, onClick = { editorTarget = alarm showEditor = true } ) } ) } } ) if (systemItems.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) Material3SettingsGroup( title = stringResource(R.string.settings_section_system), items = systemItems ) } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun AlarmTimePickerDialog( title: String, initialHour: Int, initialMinute: Int, onDismiss: () -> Unit, onConfirm: (Int, Int) -> Unit ) { val timePickerState = rememberTimePickerState( initialHour = initialHour, initialMinute = initialMinute, is24Hour = true ) DefaultDialog( title = { Text(title) }, onDismiss = onDismiss, buttons = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } TextButton(onClick = { onConfirm(timePickerState.hour, timePickerState.minute) }) { Text(stringResource(android.R.string.ok)) } } ) { TimePicker(state = timePickerState) } } @Composable private fun AlarmSwitch( checked: Boolean, onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true ) { Switch( checked = checked, onCheckedChange = onCheckedChange, enabled = enabled, thumbContent = { Icon( painter = painterResource(if (checked) R.drawable.check else R.drawable.close), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun AlarmEditorDialog( existing: MusicAlarmEntry?, allAlarms: List, playlists: List, onDismiss: () -> Unit, onSave: (MusicAlarmEntry) -> Unit ) { val context = LocalContext.current val locale = LocalLocale.current.platformLocale val noPlaylistsText = stringResource(R.string.alarm_no_playlists) val selectPlaylistText = stringResource(R.string.alarm_select_playlist) var showPlaylistDialog by remember { mutableStateOf(false) } var showTimePickerDialog by remember { mutableStateOf(false) } var enabled by remember { mutableStateOf(existing?.enabled ?: true) } var hour by remember { mutableIntStateOf(existing?.hour ?: 7) } var minute by remember { mutableIntStateOf(existing?.minute ?: 0) } var playlistId by remember { mutableStateOf(existing?.playlistId.orEmpty()) } var randomSong by remember { mutableStateOf(existing?.randomSong ?: false) } val hasSameTimeAlarm = remember(hour, minute, existing, allAlarms) { allAlarms.any { it.id != existing?.id && it.hour == hour && it.minute == minute } } val selectedPlaylist = playlists.firstOrNull { it.id == playlistId } val hasValidPlaylist = selectedPlaylist != null if (showTimePickerDialog) { AlarmTimePickerDialog( title = stringResource(R.string.alarm_time), initialHour = hour, initialMinute = minute, onDismiss = { showTimePickerDialog = false }, onConfirm = { selectedHour, selectedMinute -> hour = selectedHour minute = selectedMinute showTimePickerDialog = false } ) } if (showPlaylistDialog) { DefaultDialog( onDismiss = { showPlaylistDialog = false }, title = { Text(stringResource(R.string.alarm_playlist)) }, buttons = { TextButton(onClick = { showPlaylistDialog = false }) { Text(stringResource(android.R.string.ok)) } } ) { if (playlists.isEmpty()) { Text(noPlaylistsText) } else { LazyColumn( modifier = Modifier .fillMaxWidth() .heightIn(max = 380.dp) ) { items(items = playlists, key = { it.id }) { playlist -> val selected = playlist.id == playlistId Card( onClick = { playlistId = playlist.id showPlaylistDialog = false }, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), colors = CardDefaults.cardColors( containerColor = if (selected) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) } ) ) { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = playlist.title, style = MaterialTheme.typography.titleSmall ) Text( text = pluralStringResource( R.plurals.alarm_playlist_song_count, playlist.songCount, playlist.songCount ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } if (selected) { Icon( painter = painterResource(R.drawable.check), contentDescription = stringResource(R.string.alarm_selected), modifier = Modifier.size(18.dp) ) } } } } } } } } DefaultDialog( onDismiss = onDismiss, title = { Text( if (existing == null) { stringResource(R.string.alarm_new) } else { stringResource(R.string.alarm_edit) } ) }, buttons = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } TextButton( enabled = !hasSameTimeAlarm && hasValidPlaylist, onClick = { if (hasSameTimeAlarm) { return@TextButton } if (!hasValidPlaylist) { Toast.makeText(context, selectPlaylistText, Toast.LENGTH_SHORT).show() return@TextButton } onSave( (existing ?: MusicAlarmStore.createEmpty()).copy( enabled = enabled, hour = hour, minute = minute, playlistId = playlistId, randomSong = randomSong ) ) } ) { Text(stringResource(R.string.alarm_save)) } } ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(R.string.alarm_enabled), modifier = Modifier.weight(1f) ) AlarmSwitch(checked = enabled, onCheckedChange = { enabled = it }) } FilledTonalButton( onClick = { showTimePickerDialog = true }, modifier = Modifier.fillMaxWidth() ) { Text( text = stringResource( R.string.alarm_time_picker_value, String.format(locale, "%02d:%02d", hour, minute) ) ) } HorizontalDivider() OutlinedButton( onClick = { if (playlists.isEmpty()) { Toast.makeText(context, noPlaylistsText, Toast.LENGTH_SHORT).show() } else { showPlaylistDialog = true } }, enabled = playlists.isNotEmpty(), modifier = Modifier.fillMaxWidth() ) { Text(text = selectedPlaylist?.title ?: selectPlaylistText) } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text( text = stringResource(R.string.alarm_random_song), modifier = Modifier.weight(1f) ) AlarmSwitch(checked = randomSong, onCheckedChange = { randomSong = it }) } if (hasSameTimeAlarm) { Text( text = stringResource(R.string.alarm_duplicate_time_warning), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AndroidAutoSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding 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.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.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults 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.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.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.AndroidAutoSectionsOrderKey import com.metrolist.music.constants.AndroidAutoTargetPlaylistKey import com.metrolist.music.constants.AndroidAutoYouTubePlaylistsKey import com.metrolist.music.constants.MediaSessionConstants import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.component.PreferenceEntry import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.flow.map import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState enum class AndroidAutoSection(val id: String) { LIKED("liked"), SONGS("songs"), ARTISTS("artists"), ALBUMS("albums"), PLAYLISTS("playlists"), } @Composable fun AndroidAutoSection.label(): String = when (this) { AndroidAutoSection.LIKED -> stringResource(R.string.liked_songs) AndroidAutoSection.SONGS -> stringResource(R.string.songs) AndroidAutoSection.ARTISTS -> stringResource(R.string.artists) AndroidAutoSection.ALBUMS -> stringResource(R.string.albums) AndroidAutoSection.PLAYLISTS -> stringResource(R.string.playlists) } fun serializeSections(sections: List>): String = sections.joinToString(",") { (section, enabled) -> "${section.id}:$enabled" } fun deserializeSections(raw: String): List> { if (raw.isBlank()) return AndroidAutoSection.values().map { it to true } return raw.split(",").mapNotNull { token -> val parts = token.split(":") if (parts.size != 2) return@mapNotNull null val section = AndroidAutoSection.values().find { it.id == parts[0] } ?: return@mapNotNull null val enabled = parts[1].toBooleanStrictOrNull() ?: true section to enabled } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun AndroidAutoSettings( navController: NavController, scrollBehavior: TopAppBarScrollBehavior, ) { val haptic = LocalHapticFeedback.current val database = LocalDatabase.current val userPlaylists by remember { database.playlistsByCreateDateAsc().map { list -> list.map { it.playlist } } }.collectAsState(initial = emptyList()) val (youtubePlaylistsEnabled, onYoutubePlaylistsChange) = rememberPreference( key = AndroidAutoYouTubePlaylistsKey, defaultValue = false ) val (sectionsRaw, onSectionsChange) = rememberPreference( key = AndroidAutoSectionsOrderKey, defaultValue = serializeSections(AndroidAutoSection.values().map { it to true }) ) val (targetPlaylist, onTargetPlaylistChange) = rememberPreference( key = AndroidAutoTargetPlaylistKey, defaultValue = MediaSessionConstants.TARGET_PLAYLIST_AUTO ) var sections by remember(sectionsRaw) { mutableStateOf(deserializeSections(sectionsRaw)) } val lazyListState = rememberLazyListState() val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> val fromReal = from.index val toReal = to.index if (fromReal >= 0 && toReal >= 0 && fromReal < sections.size && toReal < sections.size) { sections = sections.toMutableList().apply { add(toReal, removeAt(fromReal)) } onSectionsChange(serializeSections(sections)) haptic.performHapticFeedback(HapticFeedbackType.LongPress) } } val playlistOptions = listOf(MediaSessionConstants.TARGET_PLAYLIST_AUTO) + userPlaylists.map { it.id } val playlistLabels: @Composable (String) -> String = { id -> if (id == MediaSessionConstants.TARGET_PLAYLIST_AUTO) { stringResource(R.string.android_auto_target_playlist_auto) } else { userPlaylists.find { it.id == id }?.name ?: id } } Column( modifier = Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { // Visible sections Material3SettingsGroup( title = stringResource(R.string.android_auto_visible_sections), items = listOf( Material3SettingsItem( title = {}, description = { Text(stringResource(R.string.android_auto_reorder_hint)) }, onClick = null ) ) ) LazyColumn( state = lazyListState, modifier = Modifier .fillMaxWidth() .height((sections.size * 80).dp), userScrollEnabled = false, ) { items(sections, key = { (section, _) -> section.id }) { (section, enabled) -> ReorderableItem(reorderableState, key = section.id) { PreferenceEntry( modifier = Modifier.fillMaxWidth(), icon = { Icon( painter = painterResource( when (section) { AndroidAutoSection.LIKED -> R.drawable.favorite AndroidAutoSection.SONGS -> R.drawable.music_note AndroidAutoSection.ARTISTS -> R.drawable.artist AndroidAutoSection.ALBUMS -> R.drawable.album AndroidAutoSection.PLAYLISTS -> R.drawable.queue_music } ), contentDescription = null, ) }, title = { Text(section.label()) }, trailingContent = { Row(verticalAlignment = Alignment.CenterVertically) { Icon( painter = painterResource(R.drawable.drag_handle), contentDescription = null, modifier = Modifier .size(24.dp) .longPressDraggableHandle( onDragStarted = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } ), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.width(12.dp)) Switch( checked = enabled, onCheckedChange = { newValue -> sections = sections.map { (s, e) -> if (s == section) s to newValue else s to e } onSectionsChange(serializeSections(sections)) }, thumbContent = { Icon( painter = painterResource( if (enabled) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) } ) } }, onClick = { sections = sections.map { (s, e) -> if (s == section) s to !e else s to e } onSectionsChange(serializeSections(sections)) }, ) } } } Spacer(Modifier.height(27.dp)) // Quick-add destination playlist var showTargetPlaylistDialog by remember { mutableStateOf(false) } if (showTargetPlaylistDialog) { androidx.compose.material3.AlertDialog( onDismissRequest = { showTargetPlaylistDialog = false }, title = { Text(stringResource(R.string.android_auto_target_playlist)) }, text = { androidx.compose.foundation.lazy.LazyColumn { items(playlistOptions) { value -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clickable { showTargetPlaylistDialog = false onTargetPlaylistChange(value) } .padding(vertical = 12.dp), ) { androidx.compose.material3.RadioButton( selected = value == targetPlaylist, onClick = null, ) Text( text = playlistLabels(value), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 16.dp), ) } } } }, confirmButton = { androidx.compose.material3.TextButton( onClick = { showTargetPlaylistDialog = false } ) { Text(stringResource(android.R.string.cancel)) } } ) } Material3SettingsGroup( title = stringResource(R.string.android_auto_target_playlist), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.playlist_add), title = { Text(stringResource(R.string.android_auto_target_playlist)) }, description = { Text(playlistLabels(targetPlaylist)) }, onClick = { showTargetPlaylistDialog = true } ) ) ) Spacer(Modifier.height(27.dp)) // YouTube playlists Material3SettingsGroup( title = stringResource(R.string.your_youtube_playlists), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.queue_music), title = { Text(stringResource(R.string.android_auto_youtube_playlists)) }, description = { Text(stringResource(R.string.android_auto_youtube_playlists_desc)) }, trailingContent = { Switch( checked = youtubePlaylistsEnabled, onCheckedChange = onYoutubePlaylistsChange, thumbContent = { Icon( painter = painterResource( if (youtubePlaylistsEnabled) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) } ) }, onClick = { onYoutubePlaylistsChange(!youtubePlaylistsEnabled) } ) ) ) Spacer(Modifier.height(27.dp)) } TopAppBar( title = { Text(stringResource(R.string.android_auto)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, scrollBehavior = scrollBehavior, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/AppearanceSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import android.app.Activity import android.content.Context import android.content.Intent import android.os.Build import androidx.compose.foundation.border import androidx.core.content.edit import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme 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.aspectRatio 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.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.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar 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.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.toArgb 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.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.ChipSortTypeKey import com.metrolist.music.constants.CropAlbumArtKey import com.metrolist.music.constants.DefaultOpenTabKey import com.metrolist.music.constants.DensityScale import com.metrolist.music.constants.DensityScaleKey import com.metrolist.music.constants.DynamicThemeKey import com.metrolist.music.constants.EnableDynamicIconKey import com.metrolist.music.constants.EnableHighRefreshRateKey import com.metrolist.music.constants.GridItemSize import com.metrolist.music.constants.GridItemsSizeKey import com.metrolist.music.constants.HidePlayerThumbnailKey import com.metrolist.music.constants.LibraryFilter import com.metrolist.music.constants.ListenTogetherInTopBarKey import com.metrolist.music.constants.LyricsAnimationStyle import com.metrolist.music.constants.LyricsAnimationStyleKey import com.metrolist.music.constants.LyricsClickKey import com.metrolist.music.constants.LyricsGlowEffectKey import com.metrolist.music.constants.LyricsLineSpacingKey import com.metrolist.music.constants.LyricsScrollKey import com.metrolist.music.constants.LyricsTextPositionKey import com.metrolist.music.constants.LyricsTextSizeKey import com.metrolist.music.constants.PlayerBackgroundStyle import com.metrolist.music.constants.PlayerBackgroundStyleKey import com.metrolist.music.constants.PlayerButtonsStyle import com.metrolist.music.constants.PlayerButtonsStyleKey import com.metrolist.music.constants.PureBlackMiniPlayerKey import com.metrolist.music.constants.SelectedThemeColorKey import com.metrolist.music.constants.ShowCachedPlaylistKey import com.metrolist.music.constants.ShowDownloadedPlaylistKey import com.metrolist.music.constants.ShowLikedPlaylistKey import com.metrolist.music.constants.ShowTopPlaylistKey import com.metrolist.music.constants.ShowUploadedPlaylistKey import com.metrolist.music.constants.SliderStyle import com.metrolist.music.constants.SliderStyleKey import com.metrolist.music.constants.SlimNavBarKey import com.metrolist.music.constants.SquigglySliderKey import com.metrolist.music.constants.SwipeSensitivityKey import com.metrolist.music.constants.SwipeThumbnailKey import com.metrolist.music.constants.SwipeToRemoveSongKey import com.metrolist.music.constants.SwipeToSongKey import com.metrolist.music.constants.UseNewMiniPlayerDesignKey import com.metrolist.music.constants.UseNewPlayerDesignKey import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.EnumDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.component.PlayerSliderTrack import com.metrolist.music.ui.component.SquigglySlider import com.metrolist.music.ui.component.WavySlider import com.metrolist.music.ui.theme.DefaultThemeColor import com.metrolist.music.ui.theme.PlayerSliderColors import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.IconUtils import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.launch import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppearanceSettings( navController: NavController, activity: Activity, snackbarHostState: SnackbarHostState, ) { val (dynamicTheme, onDynamicThemeChange) = rememberPreference( DynamicThemeKey, defaultValue = true ) val (enableDynamicIcon, onEnableDynamicIconChange) = rememberPreference( EnableDynamicIconKey, defaultValue = true ) val (enableHighRefreshRate, onEnableHighRefreshRateChange) = rememberPreference( EnableHighRefreshRateKey, defaultValue = true ) val (selectedThemeColorInt) = rememberPreference( SelectedThemeColorKey, defaultValue = DefaultThemeColor.toArgb() ) // Check if user has selected a custom color (not the default/dynamic color) val isUsingCustomColor = selectedThemeColorInt != DefaultThemeColor.toArgb() val coroutineScope = rememberCoroutineScope() fun handleIconChange(enabled: Boolean) { onEnableDynamicIconChange(enabled) IconUtils.setIcon(activity, enabled) coroutineScope.launch { val result = snackbarHostState.showSnackbar( message = "Icon updated, restart to apply", actionLabel = "Restart" ) if (result == SnackbarResult.ActionPerformed) { val packageManager = activity.packageManager val intent = packageManager.getLaunchIntentForPackage(activity.packageName) val componentName = intent?.component val mainIntent = Intent.makeRestartActivityTask(componentName) activity.startActivity(mainIntent) Runtime.getRuntime().exit(0) } } } val (useNewPlayerDesign, onUseNewPlayerDesignChange) = rememberPreference( UseNewPlayerDesignKey, defaultValue = true ) val (useNewMiniPlayerDesign, onUseNewMiniPlayerDesignChange) = rememberPreference( UseNewMiniPlayerDesignKey, defaultValue = true ) val (hidePlayerThumbnail, onHidePlayerThumbnailChange) = rememberPreference( HidePlayerThumbnailKey, defaultValue = false ) val (cropAlbumArt, onCropAlbumArtChange) = rememberPreference( CropAlbumArtKey, defaultValue = false ) val (playerBackground, onPlayerBackgroundChange) = rememberEnumPreference( PlayerBackgroundStyleKey, defaultValue = PlayerBackgroundStyle.DEFAULT, ) val (defaultOpenTab, onDefaultOpenTabChange) = rememberEnumPreference( DefaultOpenTabKey, defaultValue = NavigationTab.HOME ) val (playerButtonsStyle, onPlayerButtonsStyleChange) = rememberEnumPreference( PlayerButtonsStyleKey, defaultValue = PlayerButtonsStyle.DEFAULT ) val (lyricsPosition, onLyricsPositionChange) = rememberEnumPreference( LyricsTextPositionKey, defaultValue = LyricsPosition.CENTER ) val (lyricsClick, onLyricsClickChange) = rememberPreference(LyricsClickKey, defaultValue = true) val (lyricsScroll, onLyricsScrollChange) = rememberPreference( LyricsScrollKey, defaultValue = true ) val (lyricsAnimationStyle, onLyricsAnimationStyleChange) = rememberEnumPreference( LyricsAnimationStyleKey, defaultValue = LyricsAnimationStyle.NONE ) val (lyricsTextSize, onLyricsTextSizeChange) = rememberPreference(LyricsTextSizeKey, defaultValue = 24f) val (lyricsLineSpacing, onLyricsLineSpacingChange) = rememberPreference(LyricsLineSpacingKey, defaultValue = 1.3f) val (lyricsGlowEffect, onLyricsGlowEffectChange) = rememberPreference(LyricsGlowEffectKey, defaultValue = false) val (sliderStyle, onSliderStyleChange) = rememberEnumPreference( SliderStyleKey, defaultValue = SliderStyle.DEFAULT ) val (squigglySlider, onSquigglySliderChange) = rememberPreference( SquigglySliderKey, defaultValue = false ) val (swipeThumbnail, onSwipeThumbnailChange) = rememberPreference( SwipeThumbnailKey, defaultValue = true ) val (swipeSensitivity, onSwipeSensitivityChange) = rememberPreference( SwipeSensitivityKey, defaultValue = 0.73f ) val (gridItemSize, onGridItemSizeChange) = rememberEnumPreference( GridItemsSizeKey, defaultValue = GridItemSize.SMALL ) val (slimNav, onSlimNavChange) = rememberPreference( SlimNavBarKey, defaultValue = false ) // Density scale preferences val context = activity as Context val sharedPreferences = remember { context.getSharedPreferences("metrolist_settings", Context.MODE_PRIVATE) } val prefDensityScale = remember(sharedPreferences) { sharedPreferences.getFloat("density_scale_factor", 1.0f) } val (densityScale, setDensityScale) = rememberPreference(DensityScaleKey, defaultValue = prefDensityScale) var showRestartDialog by rememberSaveable { mutableStateOf(false) } var showDensityScaleDialog by rememberSaveable { mutableStateOf(false) } val onDensityScaleChange: (Float) -> Unit = { newScale -> setDensityScale(newScale) // Write to SharedPreferences for DensityScaler to read on next startup sharedPreferences.edit { putFloat("density_scale_factor", newScale) } showRestartDialog = true } val (listenTogetherInTopBar, onListenTogetherInTopBarChange) = rememberPreference( ListenTogetherInTopBarKey, defaultValue = true ) val (swipeToSong, onSwipeToSongChange) = rememberPreference( SwipeToSongKey, defaultValue = false ) val (swipeToRemoveSong, onSwipeToRemoveSongChange) = rememberPreference( SwipeToRemoveSongKey, defaultValue = false ) val (showLikedPlaylist, onShowLikedPlaylistChange) = rememberPreference( ShowLikedPlaylistKey, defaultValue = true ) val (showDownloadedPlaylist, onShowDownloadedPlaylistChange) = rememberPreference( ShowDownloadedPlaylistKey, defaultValue = true ) val (showTopPlaylist, onShowTopPlaylistChange) = rememberPreference( ShowTopPlaylistKey, defaultValue = true ) val (showUploadedPlaylist, onShowUploadedPlaylistChange) = rememberPreference( ShowUploadedPlaylistKey, defaultValue = true ) val (showCachedPlaylist, onShowCachedPlaylistChange) = rememberPreference( ShowCachedPlaylistKey, defaultValue = true ) val availableBackgroundStyles = PlayerBackgroundStyle.entries.filter { it != PlayerBackgroundStyle.BLUR || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S } val (defaultChip, onDefaultChipChange) = rememberEnumPreference( key = ChipSortTypeKey, defaultValue = LibraryFilter.LIBRARY ) var showSliderOptionDialog by rememberSaveable { mutableStateOf(false) } var showPlayerBackgroundDialog by rememberSaveable { mutableStateOf(false) } var showPlayerButtonsStyleDialog by rememberSaveable { mutableStateOf(false) } var showLyricsPositionDialog by rememberSaveable { mutableStateOf(false) } var showLyricsAnimationStyleDialog by rememberSaveable { mutableStateOf(false) } var showLyricsTextSizeDialog by rememberSaveable { mutableStateOf(false) } var showLyricsLineSpacingDialog by rememberSaveable { mutableStateOf(false) } if (showLyricsPositionDialog) { EnumDialog( onDismiss = { showLyricsPositionDialog = false }, onSelect = { onLyricsPositionChange(it) showLyricsPositionDialog = false }, title = stringResource(R.string.lyrics_text_position), current = lyricsPosition, values = LyricsPosition.values().toList(), valueText = { when (it) { LyricsPosition.LEFT -> stringResource(R.string.left) LyricsPosition.CENTER -> stringResource(R.string.center) LyricsPosition.RIGHT -> stringResource(R.string.right) } } ) } if (showLyricsAnimationStyleDialog) { EnumDialog( onDismiss = { showLyricsAnimationStyleDialog = false }, onSelect = { onLyricsAnimationStyleChange(it) showLyricsAnimationStyleDialog = false }, title = stringResource(R.string.lyrics_animation_style), current = lyricsAnimationStyle, values = LyricsAnimationStyle.values().toList(), valueText = { when (it) { LyricsAnimationStyle.NONE -> stringResource(R.string.none) LyricsAnimationStyle.FADE -> stringResource(R.string.fade) LyricsAnimationStyle.GLOW -> stringResource(R.string.glow) LyricsAnimationStyle.SLIDE -> stringResource(R.string.slide) LyricsAnimationStyle.KARAOKE -> stringResource(R.string.karaoke) LyricsAnimationStyle.APPLE -> stringResource(R.string.apple_music_style) } } ) } if (showLyricsTextSizeDialog) { var tempTextSize by remember { mutableFloatStateOf(lyricsTextSize) } DefaultDialog( onDismiss = { tempTextSize = lyricsTextSize showLyricsTextSizeDialog = false }, buttons = { TextButton( onClick = { tempTextSize = 24f } ) { Text(stringResource(R.string.reset)) } Spacer(modifier = Modifier.weight(1f)) TextButton( onClick = { tempTextSize = lyricsTextSize showLyricsTextSizeDialog = false } ) { Text(stringResource(android.R.string.cancel)) } TextButton( onClick = { onLyricsTextSizeChange(tempTextSize) showLyricsTextSizeDialog = false } ) { Text(stringResource(android.R.string.ok)) } } ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { Text( text = stringResource(R.string.lyrics_text_size), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp) ) Text( text = "${tempTextSize.roundToInt()} sp", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(bottom = 16.dp) ) Slider( value = tempTextSize, onValueChange = { tempTextSize = it }, valueRange = 16f..36f, steps = 19, modifier = Modifier.fillMaxWidth() ) } } } if (showLyricsLineSpacingDialog) { var tempLineSpacing by remember { mutableFloatStateOf(lyricsLineSpacing) } DefaultDialog( onDismiss = { tempLineSpacing = lyricsLineSpacing showLyricsLineSpacingDialog = false }, buttons = { TextButton( onClick = { tempLineSpacing = 1.3f } ) { Text(stringResource(R.string.reset)) } Spacer(modifier = Modifier.weight(1f)) TextButton( onClick = { tempLineSpacing = lyricsLineSpacing showLyricsLineSpacingDialog = false } ) { Text(stringResource(android.R.string.cancel)) } TextButton( onClick = { onLyricsLineSpacingChange(tempLineSpacing) showLyricsLineSpacingDialog = false } ) { Text(stringResource(android.R.string.ok)) } } ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { Text( text = stringResource(R.string.lyrics_line_spacing), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp) ) Text( text = "${String.format("%.1f", tempLineSpacing)}x", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(bottom = 16.dp) ) Slider( value = tempLineSpacing, onValueChange = { tempLineSpacing = it }, valueRange = 1.0f..2.0f, steps = 19, modifier = Modifier.fillMaxWidth() ) } } } if (showPlayerButtonsStyleDialog) { EnumDialog( onDismiss = { showPlayerButtonsStyleDialog = false }, onSelect = { onPlayerButtonsStyleChange(it) showPlayerButtonsStyleDialog = false }, title = stringResource(R.string.player_buttons_style), current = playerButtonsStyle, values = PlayerButtonsStyle.values().toList(), valueText = { when (it) { PlayerButtonsStyle.DEFAULT -> stringResource(R.string.default_style) PlayerButtonsStyle.PRIMARY -> stringResource(R.string.primary_color_style) PlayerButtonsStyle.TERTIARY -> stringResource(R.string.tertiary_color_style) } } ) } if (showPlayerBackgroundDialog) { EnumDialog( onDismiss = { showPlayerBackgroundDialog = false }, onSelect = { onPlayerBackgroundChange(it) showPlayerBackgroundDialog = false }, title = stringResource(R.string.player_background_style), current = playerBackground, values = availableBackgroundStyles, valueText = { when (it) { PlayerBackgroundStyle.DEFAULT -> stringResource(R.string.follow_theme) PlayerBackgroundStyle.GRADIENT -> stringResource(R.string.gradient) PlayerBackgroundStyle.BLUR -> stringResource(R.string.player_background_blur) } } ) } var showDefaultOpenTabDialog by rememberSaveable { mutableStateOf(false) } if (showDefaultOpenTabDialog) { EnumDialog( onDismiss = { showDefaultOpenTabDialog = false }, onSelect = { onDefaultOpenTabChange(it) showDefaultOpenTabDialog = false }, title = stringResource(R.string.default_open_tab), current = defaultOpenTab, values = NavigationTab.values().toList(), valueText = { when (it) { NavigationTab.HOME -> stringResource(R.string.home) NavigationTab.SEARCH -> stringResource(R.string.search) NavigationTab.LIBRARY -> stringResource(R.string.filter_library) } } ) } var showDefaultChipDialog by rememberSaveable { mutableStateOf(false) } if (showDefaultChipDialog) { EnumDialog( onDismiss = { showDefaultChipDialog = false }, onSelect = { onDefaultChipChange(it) showDefaultChipDialog = false }, title = stringResource(R.string.default_lib_chips), current = defaultChip, values = LibraryFilter.values().toList(), 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.PODCASTS -> stringResource(R.string.filter_podcasts) LibraryFilter.LIBRARY -> stringResource(R.string.filter_library) } } ) } var showGridSizeDialog by rememberSaveable { mutableStateOf(false) } if (showGridSizeDialog) { EnumDialog( onDismiss = { showGridSizeDialog = false }, onSelect = { onGridItemSizeChange(it) showGridSizeDialog = false }, title = stringResource(R.string.grid_cell_size), current = gridItemSize, values = GridItemSize.values().toList(), valueText = { when (it) { GridItemSize.BIG -> stringResource(R.string.big) GridItemSize.SMALL -> stringResource(R.string.small) } } ) } if (showRestartDialog) { DefaultDialog( onDismiss = { showRestartDialog = false }, buttons = { TextButton( onClick = { showRestartDialog = false } ) { Text(text = stringResource(android.R.string.cancel)) } TextButton( onClick = { showRestartDialog = false val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) } context.startActivity(intent) Runtime.getRuntime().exit(0) } ) { Text(text = stringResource(R.string.restart)) } } ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( text = stringResource(R.string.restart_required), style = MaterialTheme.typography.titleLarge ) Text( text = stringResource(R.string.density_restart_message), style = MaterialTheme.typography.bodyMedium ) } } } if (showDensityScaleDialog) { DefaultDialog( onDismiss = { showDensityScaleDialog = false }, buttons = { TextButton( onClick = { showDensityScaleDialog = false } ) { Text(text = stringResource(android.R.string.cancel)) } } ) { Column { DensityScale.entries.forEach { scale -> Row( modifier = Modifier .fillMaxWidth() .clickable { onDensityScaleChange(scale.value) showDensityScaleDialog = false } .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = scale.label, style = MaterialTheme.typography.bodyLarge, color = if (densityScale == scale.value) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface } ) } } } } } if (showSliderOptionDialog) { DefaultDialog( buttons = { TextButton( onClick = { showSliderOptionDialog = false } ) { Text(text = stringResource(android.R.string.cancel)) } }, onDismiss = { showSliderOptionDialog = false } ) { val sliderPreviewColors = PlayerSliderColors.getSliderColors( MaterialTheme.colorScheme.primary, PlayerBackgroundStyle.DEFAULT, isSystemInDarkTheme() ) Column( verticalArrangement = Arrangement.spacedBy(12.dp) ) { Row( horizontalArrangement = Arrangement.spacedBy(12.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 && !squigglySlider) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp) ) .clickable { onSliderStyleChange(SliderStyle.DEFAULT) onSquigglySliderChange(false) showSliderOptionDialog = false } .padding(12.dp) ) { val sliderValue = 0.35f Slider( value = sliderValue, valueRange = 0f..1f, onValueChange = { /* preview only */ }, colors = sliderPreviewColors, enabled = false, modifier = Modifier.weight(1f) ) Text( text = stringResource(R.string.default_), style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) } 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.WAVY && !squigglySlider) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp) ) .clickable { onSliderStyleChange(SliderStyle.WAVY) onSquigglySliderChange(false) showSliderOptionDialog = false } .padding(12.dp) ) { val sliderValue = 0.5f WavySlider( value = sliderValue, valueRange = 0f..1f, onValueChange = { /* preview only */ }, colors = sliderPreviewColors, modifier = Modifier.weight(1f), isPlaying = true, enabled = false ) Text( text = stringResource(R.string.wavy), style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) } } Row( horizontalArrangement = Arrangement.spacedBy(12.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.SLIM) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp) ) .clickable { onSliderStyleChange(SliderStyle.SLIM) onSquigglySliderChange(false) showSliderOptionDialog = false } .padding(12.dp) ) { val sliderValue = 0.65f Slider( value = sliderValue, valueRange = 0f..1f, onValueChange = { /* preview only */ }, thumb = { Spacer(modifier = Modifier.size(0.dp)) }, track = { sliderState -> PlayerSliderTrack( sliderState = sliderState, colors = sliderPreviewColors ) }, colors = sliderPreviewColors, enabled = false, modifier = Modifier.weight(1f) ) Text( text = stringResource(R.string.slim), style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) } 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.WAVY && squigglySlider) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp) ) .clickable { onSliderStyleChange(SliderStyle.WAVY) onSquigglySliderChange(true) showSliderOptionDialog = false } .padding(12.dp) ) { val sliderValue = 0.5f SquigglySlider( value = sliderValue, valueRange = 0f..1f, onValueChange = { /* preview only */ }, modifier = Modifier.weight(1f), enabled = false, colors = sliderPreviewColors, isPlaying = true, ) Text( text = stringResource(R.string.squiggly), style = MaterialTheme.typography.labelSmall, maxLines = 2, overflow = TextOverflow.Ellipsis ) } } } } } Column( Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { Material3SettingsGroup( title = stringResource(R.string.theme), items = buildList { add( Material3SettingsItem( icon = painterResource(R.drawable.ic_dynamic_icon), title = { Text(stringResource(R.string.enable_dynamic_icon)) }, trailingContent = { Switch( checked = enableDynamicIcon, onCheckedChange = { handleIconChange(it) }, thumbContent = { Icon( painter = painterResource( id = if (enableDynamicIcon) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { handleIconChange(!enableDynamicIcon) } ) ) add( Material3SettingsItem( icon = painterResource(R.drawable.speed), title = { Text(stringResource(R.string.enable_high_refresh_rate)) }, description = { Text(stringResource(R.string.enable_high_refresh_rate_desc)) }, trailingContent = { Switch( checked = enableHighRefreshRate, onCheckedChange = onEnableHighRefreshRateChange, thumbContent = { Icon( painter = painterResource( id = if (enableHighRefreshRate) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onEnableHighRefreshRateChange(!enableHighRefreshRate) } ) ) // Only show dynamic theme option when using the default/dynamic color // When a custom color is selected, dynamic theme is automatically disabled if (!isUsingCustomColor) { add( Material3SettingsItem( icon = painterResource(R.drawable.palette), title = { Text(stringResource(R.string.enable_dynamic_theme)) }, trailingContent = { Switch( checked = dynamicTheme, onCheckedChange = onDynamicThemeChange, thumbContent = { Icon( painter = painterResource( id = if (dynamicTheme) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onDynamicThemeChange(!dynamicTheme) } ) ) } add( Material3SettingsItem( icon = painterResource(R.drawable.palette), title = { Text(stringResource(R.string.theme)) }, description = { Text(stringResource(R.string.theme_desc)) }, onClick = { navController.navigate("settings/appearance/theme") } ) ) } ) Spacer(modifier = Modifier.height(27.dp)) val (pureBlackMiniPlayer, onPureBlackMiniPlayerChange) = rememberPreference( PureBlackMiniPlayerKey, defaultValue = false ) Material3SettingsGroup( title = stringResource(id = R.string.mini_player), items = buildList { add( Material3SettingsItem( icon = painterResource(R.drawable.nav_bar), title = { Text(stringResource(R.string.new_mini_player_design)) }, trailingContent = { Switch( checked = useNewMiniPlayerDesign, onCheckedChange = onUseNewMiniPlayerDesignChange, thumbContent = { Icon( painter = painterResource( id = if (useNewMiniPlayerDesign) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onUseNewMiniPlayerDesignChange(!useNewMiniPlayerDesign) } ) ) add( Material3SettingsItem( icon = painterResource(R.drawable.contrast), title = { Text(stringResource(R.string.pure_black_mini_player)) }, trailingContent = { Switch( checked = pureBlackMiniPlayer, onCheckedChange = onPureBlackMiniPlayerChange, thumbContent = { Icon( painter = painterResource( id = if (pureBlackMiniPlayer) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onPureBlackMiniPlayerChange(!pureBlackMiniPlayer) } ) ) } ) Spacer(modifier = Modifier.height(27.dp)) var showSensitivityDialog by rememberSaveable { mutableStateOf(false) } Material3SettingsGroup( title = stringResource(R.string.player), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.palette), title = { Text(stringResource(R.string.new_player_design)) }, trailingContent = { Switch( checked = useNewPlayerDesign, onCheckedChange = onUseNewPlayerDesignChange, thumbContent = { Icon( painter = painterResource( id = if (useNewPlayerDesign) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onUseNewPlayerDesignChange(!useNewPlayerDesign) } ), Material3SettingsItem( icon = painterResource(R.drawable.gradient), title = { Text(stringResource(R.string.player_background_style)) }, description = { Text( when (playerBackground) { PlayerBackgroundStyle.DEFAULT -> stringResource(R.string.follow_theme) PlayerBackgroundStyle.GRADIENT -> stringResource(R.string.gradient) PlayerBackgroundStyle.BLUR -> stringResource(R.string.player_background_blur) } ) }, onClick = { showPlayerBackgroundDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.hide_image), title = { Text(stringResource(R.string.hide_player_thumbnail)) }, description = { Text(stringResource(R.string.hide_player_thumbnail_desc)) }, trailingContent = { Switch( checked = hidePlayerThumbnail, onCheckedChange = onHidePlayerThumbnailChange, thumbContent = { Icon( painter = painterResource( id = if (hidePlayerThumbnail) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onHidePlayerThumbnailChange(!hidePlayerThumbnail) } ), Material3SettingsItem( icon = painterResource(R.drawable.crop), title = { Text(stringResource(R.string.crop_album_art)) }, description = { Text(stringResource(R.string.crop_album_art_desc)) }, trailingContent = { Switch( checked = cropAlbumArt, onCheckedChange = onCropAlbumArtChange, thumbContent = { Icon( painter = painterResource( id = if (cropAlbumArt) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onCropAlbumArtChange(!cropAlbumArt) } ), Material3SettingsItem( icon = painterResource(R.drawable.palette), title = { Text(stringResource(R.string.player_buttons_style)) }, description = { Text( when (playerButtonsStyle) { PlayerButtonsStyle.DEFAULT -> stringResource(R.string.default_style) PlayerButtonsStyle.PRIMARY -> stringResource(R.string.primary_color_style) PlayerButtonsStyle.TERTIARY -> stringResource(R.string.tertiary_color_style) } ) }, onClick = { showPlayerButtonsStyleDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.sliders), title = { Text(stringResource(R.string.player_slider_style)) }, description = { Text( when (sliderStyle) { SliderStyle.DEFAULT -> stringResource(R.string.default_) SliderStyle.WAVY -> if (squigglySlider) stringResource(R.string.squiggly) else stringResource( R.string.wavy ) SliderStyle.SLIM -> stringResource(R.string.slim) } ) }, onClick = { showSliderOptionDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.swipe), title = { Text(stringResource(R.string.enable_swipe_thumbnail)) }, trailingContent = { Switch( checked = swipeThumbnail, onCheckedChange = onSwipeThumbnailChange, thumbContent = { Icon( painter = painterResource( id = if (swipeThumbnail) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onSwipeThumbnailChange(!swipeThumbnail) } ) ) + if (swipeThumbnail) listOf( Material3SettingsItem( icon = painterResource(R.drawable.tune), title = { Text(stringResource(R.string.swipe_sensitivity)) }, description = { Text( stringResource( R.string.sensitivity_percentage, (swipeSensitivity * 100).roundToInt() ) ) }, onClick = { showSensitivityDialog = true } ) ) else emptyList() ) if (showSensitivityDialog) { var tempSensitivity by remember { mutableFloatStateOf(swipeSensitivity) } DefaultDialog( onDismiss = { tempSensitivity = swipeSensitivity showSensitivityDialog = false }, buttons = { TextButton( onClick = { tempSensitivity = 0.73f } ) { Text(stringResource(R.string.reset)) } Spacer(modifier = Modifier.weight(1f)) TextButton( onClick = { tempSensitivity = swipeSensitivity showSensitivityDialog = false } ) { Text(stringResource(android.R.string.cancel)) } TextButton( onClick = { onSwipeSensitivityChange(tempSensitivity) showSensitivityDialog = false } ) { Text(stringResource(android.R.string.ok)) } } ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { Text( text = stringResource(R.string.swipe_sensitivity), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp) ) Text( text = stringResource( R.string.sensitivity_percentage, (tempSensitivity * 100).roundToInt() ), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(bottom = 16.dp) ) Slider( value = tempSensitivity, onValueChange = { tempSensitivity = it }, valueRange = 0f..1f, modifier = Modifier.fillMaxWidth() ) } } } Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.lyrics), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.lyrics), title = { Text(stringResource(R.string.lyrics_text_position)) }, description = { Text( when (lyricsPosition) { LyricsPosition.LEFT -> stringResource(R.string.left) LyricsPosition.CENTER -> stringResource(R.string.center) LyricsPosition.RIGHT -> stringResource(R.string.right) } ) }, onClick = { showLyricsPositionDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.lyrics), title = { Text(stringResource(R.string.lyrics_animation_style)) }, description = { Text( when (lyricsAnimationStyle) { LyricsAnimationStyle.NONE -> stringResource(R.string.none) LyricsAnimationStyle.FADE -> stringResource(R.string.fade) LyricsAnimationStyle.GLOW -> stringResource(R.string.glow) LyricsAnimationStyle.SLIDE -> stringResource(R.string.slide) LyricsAnimationStyle.KARAOKE -> stringResource(R.string.karaoke) LyricsAnimationStyle.APPLE -> stringResource(R.string.apple_music_style) } ) }, onClick = { showLyricsAnimationStyleDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.lyrics), title = { Text(stringResource(R.string.lyrics_glow_effect)) }, description = { Text(stringResource(R.string.lyrics_glow_effect_desc)) }, trailingContent = { Switch( checked = lyricsGlowEffect, onCheckedChange = onLyricsGlowEffectChange, thumbContent = { Icon( painter = painterResource( id = if (lyricsGlowEffect) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onLyricsGlowEffectChange(!lyricsGlowEffect) } ), Material3SettingsItem( icon = painterResource(R.drawable.lyrics), title = { Text(stringResource(R.string.lyrics_text_size)) }, description = { Text("${lyricsTextSize.roundToInt()} sp") }, onClick = { showLyricsTextSizeDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.lyrics), title = { Text(stringResource(R.string.lyrics_line_spacing)) }, description = { Text("${String.format("%.1f", lyricsLineSpacing)}x") }, onClick = { showLyricsLineSpacingDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.lyrics), title = { Text(stringResource(R.string.lyrics_click_change)) }, trailingContent = { Switch( checked = lyricsClick, onCheckedChange = onLyricsClickChange, thumbContent = { Icon( painter = painterResource( id = if (lyricsClick) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onLyricsClickChange(!lyricsClick) } ), Material3SettingsItem( icon = painterResource(R.drawable.lyrics), title = { Text(stringResource(R.string.lyrics_auto_scroll)) }, trailingContent = { Switch( checked = lyricsScroll, onCheckedChange = onLyricsScrollChange, thumbContent = { Icon( painter = painterResource( id = if (lyricsScroll) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onLyricsScrollChange(!lyricsScroll) } ) ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.misc), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.nav_bar), title = { Text(stringResource(R.string.default_open_tab)) }, description = { Text( when (defaultOpenTab) { NavigationTab.HOME -> stringResource(R.string.home) NavigationTab.SEARCH -> stringResource(R.string.search) NavigationTab.LIBRARY -> stringResource(R.string.filter_library) } ) }, onClick = { showDefaultOpenTabDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.tab), title = { Text(stringResource(R.string.default_lib_chips)) }, description = { Text( when (defaultChip) { 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.PODCASTS -> stringResource(R.string.filter_podcasts) LibraryFilter.LIBRARY -> stringResource(R.string.filter_library) } ) }, onClick = { showDefaultChipDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.swipe), title = { Text(stringResource(R.string.swipe_song_to_add)) }, trailingContent = { Switch( checked = swipeToSong, onCheckedChange = onSwipeToSongChange, thumbContent = { Icon( painter = painterResource( id = if (swipeToSong) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onSwipeToSongChange(!swipeToSong) } ), Material3SettingsItem( icon = painterResource(R.drawable.swipe), title = { Text(stringResource(R.string.swipe_song_to_remove)) }, trailingContent = { Switch( checked = swipeToRemoveSong, onCheckedChange = onSwipeToRemoveSongChange, thumbContent = { Icon( painter = painterResource( id = if (swipeToRemoveSong) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onSwipeToRemoveSongChange(!swipeToRemoveSong) } ), Material3SettingsItem( icon = painterResource(R.drawable.nav_bar), title = { Text(stringResource(R.string.slim_navbar)) }, trailingContent = { Switch( checked = slimNav, onCheckedChange = onSlimNavChange, thumbContent = { Icon( painter = painterResource( id = if (slimNav) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onSlimNavChange(!slimNav) } ), Material3SettingsItem( icon = painterResource(R.drawable.group_outlined), title = { Text(stringResource(R.string.listen_together_in_top_bar)) }, description = { Text(stringResource(R.string.listen_together_in_top_bar_desc)) }, trailingContent = { Switch( checked = listenTogetherInTopBar, onCheckedChange = onListenTogetherInTopBarChange, thumbContent = { Icon( painter = painterResource( id = if (listenTogetherInTopBar) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onListenTogetherInTopBarChange(!listenTogetherInTopBar) } ), Material3SettingsItem( icon = painterResource(R.drawable.grid_view), title = { Text(stringResource(R.string.grid_cell_size)) }, description = { Text( when (gridItemSize) { GridItemSize.BIG -> stringResource(R.string.big) GridItemSize.SMALL -> stringResource(R.string.small) } ) }, onClick = { showGridSizeDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.grid_view), title = { Text(stringResource(R.string.display_density)) }, description = { Text(DensityScale.fromValue(densityScale).label) }, onClick = { showDensityScaleDialog = true } ) ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.auto_playlists), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.favorite), title = { Text(stringResource(R.string.show_liked_playlist)) }, trailingContent = { Switch( checked = showLikedPlaylist, onCheckedChange = onShowLikedPlaylistChange, thumbContent = { Icon( painter = painterResource( id = if (showLikedPlaylist) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShowLikedPlaylistChange(!showLikedPlaylist) } ), Material3SettingsItem( icon = painterResource(R.drawable.offline), title = { Text(stringResource(R.string.show_downloaded_playlist)) }, trailingContent = { Switch( checked = showDownloadedPlaylist, onCheckedChange = onShowDownloadedPlaylistChange, thumbContent = { Icon( painter = painterResource( id = if (showDownloadedPlaylist) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShowDownloadedPlaylistChange(!showDownloadedPlaylist) } ), Material3SettingsItem( icon = painterResource(R.drawable.trending_up), title = { Text(stringResource(R.string.show_top_playlist)) }, trailingContent = { Switch( checked = showTopPlaylist, onCheckedChange = onShowTopPlaylistChange, thumbContent = { Icon( painter = painterResource( id = if (showTopPlaylist) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShowTopPlaylistChange(!showTopPlaylist) } ), Material3SettingsItem( icon = painterResource(R.drawable.backup), title = { Text(stringResource(R.string.show_uploaded_playlist)) }, trailingContent = { Switch( checked = showUploadedPlaylist, onCheckedChange = onShowUploadedPlaylistChange, thumbContent = { Icon( painter = painterResource( id = if (showUploadedPlaylist) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShowUploadedPlaylistChange(!showUploadedPlaylist) } ), Material3SettingsItem( icon = painterResource(R.drawable.cached), title = { Text(stringResource(R.string.show_cached_playlist)) }, trailingContent = { Switch( checked = showCachedPlaylist, onCheckedChange = onShowCachedPlaylistChange, thumbContent = { Icon( painter = painterResource( id = if (showCachedPlaylist) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShowCachedPlaylistChange(!showCachedPlaylist) } ) ) ) Spacer(modifier = Modifier.height(16.dp)) } TopAppBar( title = { Text(stringResource(R.string.appearance)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } } ) } enum class DarkMode { ON, OFF, AUTO, } enum class NavigationTab { HOME, SEARCH, LIBRARY, } enum class LyricsPosition { LEFT, CENTER, RIGHT, } enum class PlayerTextAlignment { SIDED, CENTER, } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/BackupAndRestore.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background 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.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.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll 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.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable 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.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.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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.db.entities.Song import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.menu.AddToPlaylistDialogOnline import com.metrolist.music.ui.menu.CsvColumnMappingDialog import com.metrolist.music.ui.menu.CsvImportProgressDialog import com.metrolist.music.ui.menu.LoadingScreen import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.viewmodels.BackupPreviewInfo import com.metrolist.music.viewmodels.BackupRestoreViewModel import com.metrolist.music.viewmodels.ConvertedSongLog import com.metrolist.music.viewmodels.CsvImportState import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.time.LocalDateTime import java.time.format.DateTimeFormatter @OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupAndRestore( navController: NavController, viewModel: BackupRestoreViewModel = hiltViewModel(), ) { var importedTitle by remember { mutableStateOf("") } val importedSongs = remember { mutableStateListOf() } var showChoosePlaylistDialogOnline by rememberSaveable { mutableStateOf(false) } var currentImportSong by rememberSaveable { mutableStateOf("") } var isProgressStarted by rememberSaveable { mutableStateOf(false) } var progressPercentage by rememberSaveable { mutableIntStateOf(0) } // CSV column mapping state var csvImportState by remember { mutableStateOf(null) } var showCsvColumnMapping by rememberSaveable { mutableStateOf(false) } var showCsvImportProgress by rememberSaveable { mutableStateOf(false) } var csvImportProgress by rememberSaveable { mutableIntStateOf(0) } val csvRecentLogs = remember { mutableStateListOf() } var pendingCsvUri by remember { mutableStateOf(null) } // Restore confirmation dialog state var showRestoreConfirmDialog by rememberSaveable { mutableStateOf(false) } var pendingRestoreUri by remember { mutableStateOf(null) } var backupPreviewInfo by remember { mutableStateOf(null) } var isLoadingAccountInfo by remember { mutableStateOf(false) } var accountCheckFailed by remember { mutableStateOf(false) } val context = LocalContext.current val coroutineScope = rememberCoroutineScope() val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> if (uri != null) { viewModel.backup(context, uri) } } val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null) { pendingRestoreUri = uri val preview = viewModel.previewBackup(context, uri) backupPreviewInfo = preview showRestoreConfirmDialog = true // Fetch account info asynchronously if backup has auth data accountCheckFailed = false if (preview.hasAuthData && preview.cookie != null) { isLoadingAccountInfo = true coroutineScope.launch(Dispatchers.IO) { val accountInfo = viewModel.fetchAccountInfoFromBackup(preview.cookie) if (accountInfo != null) { backupPreviewInfo = accountInfo } else { accountCheckFailed = true } isLoadingAccountInfo = false } } } } val importPlaylistFromCsv = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri == null) return@rememberLauncherForActivityResult pendingCsvUri = uri val previewState = viewModel.previewCsvFile(context, uri) csvImportState = previewState showCsvColumnMapping = 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 } } Column( Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Top, ), ), ) val appName = stringResource(R.string.app_name) Material3SettingsGroup( items = listOf( Material3SettingsItem( title = { Text(stringResource(R.string.action_backup)) }, icon = painterResource(R.drawable.backup), onClick = { val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") backupLauncher.launch( "${appName}_${ LocalDateTime.now().format(formatter) }.backup", ) }, ), Material3SettingsItem( title = { Text(stringResource(R.string.action_restore)) }, icon = painterResource(R.drawable.restore), onClick = { restoreLauncher.launch(arrayOf("application/octet-stream")) }, ), Material3SettingsItem( title = { Text(stringResource(R.string.import_online)) }, icon = painterResource(R.drawable.playlist_add), onClick = { importM3uLauncherOnline.launch(arrayOf("audio/*")) }, ), Material3SettingsItem( title = { Text(stringResource(R.string.import_csv)) }, icon = painterResource(R.drawable.playlist_add), onClick = { importPlaylistFromCsv.launch( arrayOf("text/csv", "text/comma-separated-values", "application/csv", "text/plain"), ) }, ), ), ) } TopAppBar( title = { Text(stringResource(R.string.backup_restore)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) AddToPlaylistDialogOnline( isVisible = showChoosePlaylistDialogOnline, allowSyncing = false, initialTextFieldValue = importedTitle, songs = importedSongs, onDismiss = { showChoosePlaylistDialogOnline = false }, onProgressStart = { newVal -> isProgressStarted = newVal }, onPercentageChange = { newPercentage -> progressPercentage = newPercentage }, onSongChange = { currentImportSong = it }, ) LoadingScreen( isVisible = isProgressStarted, value = progressPercentage, songTitle = currentImportSong, ) // CSV column mapping dialog csvImportState?.let { state -> CsvColumnMappingDialog( isVisible = showCsvColumnMapping, csvState = state, onDismiss = { showCsvColumnMapping = false csvImportState = null }, onConfirm = { mappingState -> showCsvColumnMapping = false csvImportState = mappingState pendingCsvUri?.let { uri -> showCsvImportProgress = true coroutineScope.launch(Dispatchers.Default) { val result = viewModel.importPlaylistFromCsv( context, uri, mappingState, onProgress = { progress -> csvImportProgress = progress }, onLogUpdate = { logs -> csvRecentLogs.clear() csvRecentLogs.addAll(logs) }, ) importedSongs.clear() importedSongs.addAll(result) if (result.isNotEmpty()) { showCsvImportProgress = false csvImportProgress = 0 csvRecentLogs.clear() showChoosePlaylistDialogOnline = true } } } }, ) } // CSV import progress dialog CsvImportProgressDialog( isVisible = showCsvImportProgress, progress = csvImportProgress, recentLogs = csvRecentLogs.toList(), onDismiss = { // Cannot dismiss while importing }, ) // Restore confirmation dialog if (showRestoreConfirmDialog) { DefaultDialog( onDismiss = { showRestoreConfirmDialog = false pendingRestoreUri = null backupPreviewInfo = null accountCheckFailed = false }, icon = { Icon( painter = painterResource(R.drawable.restore), contentDescription = null, ) }, title = { Text(stringResource(R.string.restore_confirm_title)) }, buttons = { TextButton( onClick = { showRestoreConfirmDialog = false pendingRestoreUri = null backupPreviewInfo = null accountCheckFailed = false }, ) { Text(stringResource(android.R.string.cancel)) } TextButton( onClick = { showRestoreConfirmDialog = false pendingRestoreUri?.let { uri -> viewModel.restore(context, uri, clearAuthData = true) } pendingRestoreUri = null backupPreviewInfo = null accountCheckFailed = false }, ) { Text(stringResource(R.string.restore)) } }, ) { // Supporting text Text( text = stringResource(R.string.restore_confirm_message), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) // Show warning about account sign out if account found if (backupPreviewInfo?.accountName != null) { Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.restore_account_warning), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleSmall, ) } // Show loading or account info if (isLoadingAccountInfo) { Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(16.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, ) Spacer(modifier = Modifier.size(16.dp)) Text( text = stringResource(R.string.checking_previous_account), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } // Show "No account found" if check failed OR backup has no auth data val hasNoAccount = backupPreviewInfo?.let { !it.hasAuthData || (it.hasAuthData && it.accountName == null && !isLoadingAccountInfo) } ?: false if (!isLoadingAccountInfo && (accountCheckFailed || hasNoAccount)) { Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.no_account_found), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } // Show account info if backup contains auth data and we have account details backupPreviewInfo?.let { preview -> if (!isLoadingAccountInfo && preview.hasAuthData && preview.accountName != null) { Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant, ) Spacer(modifier = Modifier.height(16.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { if (preview.accountImageUrl != null) { AsyncImage( model = preview.accountImageUrl, contentDescription = null, modifier = Modifier .size(40.dp) .clip(CircleShape), contentScale = ContentScale.Crop, ) } else { Box( modifier = Modifier .size(40.dp) .clip(CircleShape) .background(MaterialTheme.colorScheme.secondaryContainer), contentAlignment = Alignment.Center, ) { Icon( painter = painterResource(R.drawable.person), contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(24.dp), ) } } Spacer(modifier = Modifier.size(16.dp)) Text( text = preview.accountEmail ?: preview.accountName, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) } Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant, ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/ChangelogScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import androidx.compose.animation.* import androidx.compose.foundation.layout.* 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.foundation.text.ClickableText import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler 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.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import com.metrolist.music.R import com.metrolist.music.BuildConfig import com.metrolist.music.utils.ReleaseInfo import com.metrolist.music.utils.Updater private val markdownLinkRegex = Regex("(@[a-zA-Z0-9_-]+)|(https?://[\\w-]+(\\.[\\w-]+)+[\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])") @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChangelogScreen( onDismiss: () -> Unit ) { var releases by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { Updater.getAllReleases().onSuccess { allReleases -> releases = allReleases.filter { release -> Updater.compareVersions(BuildConfig.VERSION_NAME, release.tagName) >= 0 } isLoading = false }.onFailure { isLoading = false } } val sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = false ) val showFab by remember { derivedStateOf { sheetState.targetValue != SheetValue.Hidden } } ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), dragHandle = { BottomSheetDefaults.DragHandle() } ) { Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(bottom = 80.dp) ) { item { Text( text = stringResource(R.string.changelog), style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) } item { val density = LocalDensity.current val stroke = remember(density) { Stroke(width = with(density) { 3.dp.toPx() }, cap = StrokeCap.Round) } LinearWavyProgressIndicator( progress = { 1f }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp), color = MaterialTheme.colorScheme.primary, trackColor = Color.Transparent, stroke = stroke, trackStroke = stroke, amplitude = { 1f } ) } if (isLoading) { item { Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } } else if (releases.isEmpty()) { item { Box(modifier = Modifier.fillMaxWidth().height(200.dp), contentAlignment = Alignment.Center) { Text(text = stringResource(R.string.changelog_empty)) } } } else { items(releases) { release -> ReleaseItem(release) } } } androidx.compose.animation.AnimatedVisibility( visible = showFab, enter = fadeIn() + slideInVertically { it }, exit = fadeOut() + slideOutVertically { it }, modifier = Modifier .align(Alignment.BottomEnd) .padding(16.dp) ) { val githubReleasesUrl = stringResource(R.string.github_releases_url) ExtendedFloatingActionButton( onClick = { uriHandler.openUri(githubReleasesUrl) }, icon = { Icon(painterResource(R.drawable.github), contentDescription = null, modifier = Modifier.size(24.dp)) }, text = { Text(stringResource(R.string.view_on_github)) }, containerColor = MaterialTheme.colorScheme.onPrimary, contentColor = MaterialTheme.colorScheme.primary ) } } } } @Composable fun ReleaseItem(release: ReleaseInfo) { Column(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Surface( color = MaterialTheme.colorScheme.secondaryContainer, shape = CircleShape ) { Text( text = release.tagName, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSecondaryContainer ) } Text( text = release.releaseDate.split("T").firstOrNull() ?: "", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Spacer(modifier = Modifier.height(12.dp)) Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ), shape = RoundedCornerShape(16.dp) ) { Column(modifier = Modifier.padding(16.dp)) { MarkdownText(release.description) } } } } @Suppress("DEPRECATION") @Composable fun MarkdownText(text: String) { val lines = text.split("\n") val uriHandler = LocalUriHandler.current Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { lines.filter { it.isNotBlank() }.forEach { line -> val trimmedLine = line.trim() if (trimmedLine.startsWith("#")) { val level = trimmedLine.takeWhile { it == '#' }.length val headerText = trimmedLine.substring(level).trim() Box(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) { Text( text = headerText, style = when (level) { 1 -> MaterialTheme.typography.headlineMedium 2 -> MaterialTheme.typography.headlineSmall else -> MaterialTheme.typography.titleMedium }, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) } } else { val isListItem = trimmedLine.startsWith("- ") || trimmedLine.startsWith("* ") val contentText = if (isListItem) { trimmedLine.substring(2).trim() } else { trimmedLine } val annotatedString = buildAnnotatedString { var lastIndex = 0 markdownLinkRegex.findAll(contentText).forEach { result -> append(contentText.substring(lastIndex, result.range.first)) val match = result.value val link = if (match.startsWith("@")) "https://github.com/${match.substring(1)}" else match pushStringAnnotation(tag = "URL", annotation = link) withStyle(style = SpanStyle( color = MaterialTheme.colorScheme.primary, fontWeight = if (match.startsWith("@")) FontWeight.Bold else FontWeight.Normal, textDecoration = if (match.startsWith("@")) TextDecoration.None else TextDecoration.Underline )) { append(match) } pop() lastIndex = result.range.last + 1 } append(contentText.substring(lastIndex)) } Column(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) { if (isListItem) { Text( text = stringResource(R.string.list_bullet), modifier = Modifier.padding(end = 8.dp), style = MaterialTheme.typography.bodyLarge ) } ClickableText( text = annotatedString, style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), onClick = { offset -> annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset) .firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) } } ) } if (isListItem) { Spacer(modifier = Modifier.height(4.dp)) HorizontalDivider( thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) ) } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/ContentSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import android.content.Intent import android.os.Build import android.provider.Settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.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.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateListOf 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.unit.dp import androidx.core.net.toUri import androidx.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.AppLanguageKey import com.metrolist.music.constants.ContentCountryKey import com.metrolist.music.constants.ContentLanguageKey import com.metrolist.music.constants.CountryCodeToName import com.metrolist.music.constants.EnableBetterLyricsKey import com.metrolist.music.constants.EnableKugouKey import com.metrolist.music.constants.EnableLrcLibKey import com.metrolist.music.constants.EnableSimpMusicKey import com.metrolist.music.constants.EnableLyricsPlus import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.HideYoutubeShortsKey import com.metrolist.music.constants.LanguageCodeToName import com.metrolist.music.constants.LyricsProviderOrderKey import com.metrolist.music.constants.ProxyEnabledKey import com.metrolist.music.constants.ProxyPasswordKey import com.metrolist.music.constants.ProxyTypeKey import com.metrolist.music.constants.ProxyUrlKey import com.metrolist.music.constants.ProxyUsernameKey import com.metrolist.music.constants.QuickPicks import com.metrolist.music.constants.QuickPicksKey import com.metrolist.music.constants.RandomizeHomeOrderKey import com.metrolist.music.constants.SYSTEM_DEFAULT import com.metrolist.music.constants.ShowArtistDescriptionKey import com.metrolist.music.constants.ShowArtistSubscriberCountKey import com.metrolist.music.constants.ShowMonthlyListenersKey import com.metrolist.music.constants.ShowWrappedCardKey import com.metrolist.music.constants.TopSize import com.metrolist.music.ui.component.EnumDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.component.DraggableLyricsProviderItem import com.metrolist.music.ui.component.DraggableLyricsProviderList import com.metrolist.music.lyrics.LyricsProviderRegistry import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import java.net.Proxy @OptIn(ExperimentalMaterial3Api::class) @Composable fun ContentSettings( navController: NavController ) { val context = LocalContext.current // Used only before Android 13 val (appLanguage, onAppLanguageChange) = rememberPreference(key = AppLanguageKey, defaultValue = SYSTEM_DEFAULT) 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 (hideVideoSongs, onHideVideoSongsChange) = rememberPreference(key = HideVideoSongsKey, defaultValue = false) val (hideYoutubeShorts, onHideYoutubeShortsChange) = rememberPreference(key = HideYoutubeShortsKey, defaultValue = false) val (showArtistDescription, onShowArtistDescriptionChange) = rememberPreference(key = ShowArtistDescriptionKey, defaultValue = true) val (showArtistSubscriberCount, onShowArtistSubscriberCountChange) = rememberPreference(key = ShowArtistSubscriberCountKey, defaultValue = true) val (showMonthlyListeners, onShowMonthlyListenersChange) = rememberPreference(key = ShowMonthlyListenersKey, defaultValue = true) 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 (proxyUsername, onProxyUsernameChange) = rememberPreference(key = ProxyUsernameKey, defaultValue = "username") val (proxyPassword, onProxyPasswordChange) = rememberPreference(key = ProxyPasswordKey, defaultValue = "password") val (enableKugou, onEnableKugouChange) = rememberPreference(key = EnableKugouKey, defaultValue = true) val (enableLrclib, onEnableLrclibChange) = rememberPreference(key = EnableLrcLibKey, defaultValue = true) val (enableBetterLyrics, onEnableBetterLyricsChange) = rememberPreference(key = EnableBetterLyricsKey, defaultValue = true) val (enableSimpMusic, onEnableSimpMusicChange) = rememberPreference(key = EnableSimpMusicKey, defaultValue = true) val (enableLyricsPlus, onEnableLyricsPlusChange) = rememberPreference(key = EnableLyricsPlus, defaultValue = false) val (lyricsProviderOrder, onLyricsProviderOrderChange) = rememberPreference( key = LyricsProviderOrderKey, defaultValue = LyricsProviderRegistry.serializeProviderOrder(LyricsProviderRegistry.getDefaultProviderOrder()) ) val (lengthTop, onLengthTopChange) = rememberPreference(key = TopSize, defaultValue = "50") val (quickPicks, onQuickPicksChange) = rememberEnumPreference(key = QuickPicksKey, defaultValue = QuickPicks.QUICK_PICKS) val (showWrappedCard, onShowWrappedCardChange) = rememberPreference(key = ShowWrappedCardKey, defaultValue = false) val (randomizeHomeOrder, onRandomizeHomeOrderChange) = rememberPreference( RandomizeHomeOrderKey, defaultValue = true ) val providerDisplayNames = mapOf( "BetterLyrics" to "Better Lyrics", "SimpMusic" to "SimpMusic", "LrcLib" to "LrcLib", "KuGou" to "KuGou", "LyricsPlus" to "LyricsPlus", "YouTubeSubtitle" to "YouTube Subtitles", "YouTube" to "YouTube", ) var showProxyConfigurationDialog by rememberSaveable { mutableStateOf(false) } if (showProxyConfigurationDialog) { var expandedDropdown by remember { mutableStateOf(false) } var tempProxyUrl by rememberSaveable { mutableStateOf(proxyUrl) } var tempProxyUsername by rememberSaveable { mutableStateOf(proxyUsername) } var tempProxyPassword by rememberSaveable { mutableStateOf(proxyPassword) } var authEnabled by rememberSaveable { mutableStateOf(proxyUsername.isNotBlank() || proxyPassword.isNotBlank()) } AlertDialog( onDismissRequest = { showProxyConfigurationDialog = false }, title = { Text(stringResource(R.string.config_proxy)) }, text = { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { ExposedDropdownMenuBox( expanded = expandedDropdown, onExpandedChange = { expandedDropdown = !expandedDropdown }, modifier = Modifier.fillMaxWidth() ) { OutlinedTextField( value = proxyType.name, onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.proxy_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDropdown) }, colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), modifier = Modifier .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) .fillMaxWidth() ) ExposedDropdownMenu( expanded = expandedDropdown, onDismissRequest = { expandedDropdown = false } ) { listOf(Proxy.Type.HTTP, Proxy.Type.SOCKS).forEach { type -> DropdownMenuItem( text = { Text(type.name) }, onClick = { onProxyTypeChange(type) expandedDropdown = false } ) } } } OutlinedTextField( value = tempProxyUrl, onValueChange = { tempProxyUrl = it }, label = { Text(stringResource(R.string.proxy_url)) }, modifier = Modifier.fillMaxWidth() ) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { Text(stringResource(R.string.enable_authentication)) Switch( checked = authEnabled, onCheckedChange = { authEnabled = it if (!it) { tempProxyUsername = "" tempProxyPassword = "" } } ) } AnimatedVisibility(visible = authEnabled) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( value = tempProxyUsername, onValueChange = { tempProxyUsername = it }, label = { Text(stringResource(R.string.proxy_username)) }, modifier = Modifier.fillMaxWidth() ) OutlinedTextField( value = tempProxyPassword, onValueChange = { tempProxyPassword = it }, label = { Text(stringResource(R.string.proxy_password)) }, modifier = Modifier.fillMaxWidth() ) } } } }, confirmButton = { TextButton( onClick = { onProxyUrlChange(tempProxyUrl) onProxyUsernameChange(if (authEnabled) tempProxyUsername else "") onProxyPasswordChange(if (authEnabled) tempProxyPassword else "") showProxyConfigurationDialog = false } ) { Text(stringResource(R.string.save)) } }, dismissButton = { TextButton(onClick = { showProxyConfigurationDialog = false }) { Text(stringResource(R.string.cancel)) } } ) } var showContentLanguageDialog by rememberSaveable { mutableStateOf(false) } if (showContentLanguageDialog) { EnumDialog( onDismiss = { showContentLanguageDialog = false }, onSelect = { onContentLanguageChange(it) showContentLanguageDialog = false }, title = stringResource(R.string.content_language), current = contentLanguage, values = (listOf(SYSTEM_DEFAULT) + LanguageCodeToName.keys.toList()), valueText = { LanguageCodeToName.getOrElse(it) { stringResource(R.string.system_default) } } ) } var showContentCountryDialog by rememberSaveable { mutableStateOf(false) } if (showContentCountryDialog) { EnumDialog( onDismiss = { showContentCountryDialog = false }, onSelect = { onContentCountryChange(it) showContentCountryDialog = false }, title = stringResource(R.string.content_country), current = contentCountry, values = (listOf(SYSTEM_DEFAULT) + CountryCodeToName.keys.toList()), valueText = { CountryCodeToName.getOrElse(it) { stringResource(R.string.system_default) } } ) } var showAppLanguageDialog by rememberSaveable { mutableStateOf(false) } if (showAppLanguageDialog) { EnumDialog( onDismiss = { showAppLanguageDialog = false }, onSelect = { onAppLanguageChange(it) showAppLanguageDialog = false }, title = stringResource(R.string.app_language), current = appLanguage, values = (listOf(SYSTEM_DEFAULT) + LanguageCodeToName.keys.toList()), valueText = { LanguageCodeToName.getOrElse(it) { stringResource(R.string.system_default) } } ) } var showProviderSelectionDialog by rememberSaveable { mutableStateOf(false) } if (showProviderSelectionDialog) { AlertDialog( onDismissRequest = { showProviderSelectionDialog = false }, title = { Text(stringResource(R.string.lyrics_provider_selection)) }, text = { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.enable_lrclib)) Text( text = stringResource(R.string.enable_lrclib_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( checked = enableLrclib, onCheckedChange = onEnableLrclibChange, thumbContent = { Icon( painter = painterResource( id = if (enableLrclib) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.enable_kugou)) Text( text = stringResource(R.string.enable_kugou_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( checked = enableKugou, onCheckedChange = onEnableKugouChange, thumbContent = { Icon( painter = painterResource( id = if (enableKugou) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.enable_better_lyrics)) Text( text = stringResource(R.string.enable_better_lyrics_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( checked = enableBetterLyrics, onCheckedChange = onEnableBetterLyricsChange, thumbContent = { Icon( painter = painterResource( id = if (enableBetterLyrics) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.enable_simpmusic)) Text( text = stringResource(R.string.enable_simpmusic_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( checked = enableSimpMusic, onCheckedChange = onEnableSimpMusicChange, thumbContent = { Icon( painter = painterResource( id = if (enableSimpMusic) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Column( modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.enable_lyricsplus)) Text( text = stringResource(R.string.enable_lyricsplus_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } Switch( checked = enableLyricsPlus, onCheckedChange = onEnableLyricsPlusChange, thumbContent = { Icon( painter = painterResource( id = if (enableLyricsPlus) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) } Column(modifier = Modifier.padding(2.dp)) { Text( text = stringResource(R.string.youtube_music_lyrics_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } }, confirmButton = { TextButton( onClick = { showProviderSelectionDialog = false } ) { Text(stringResource(R.string.close)) } } ) } var showQuickPicksDialog by rememberSaveable { mutableStateOf(false) } if (showQuickPicksDialog) { EnumDialog( onDismiss = { showQuickPicksDialog = false }, onSelect = { onQuickPicksChange(it) showQuickPicksDialog = false }, title = stringResource(R.string.set_quick_picks), current = quickPicks, values = QuickPicks.values().toList(), valueText = { when (it) { QuickPicks.QUICK_PICKS -> stringResource(R.string.quick_picks) QuickPicks.LAST_LISTEN -> stringResource(R.string.last_song_listened) } } ) } var showTopLengthDialog by rememberSaveable { mutableStateOf(false) } if (showTopLengthDialog) { var tempLength by rememberSaveable { mutableFloatStateOf(lengthTop.toFloat()) } AlertDialog( onDismissRequest = { showTopLengthDialog = false }, title = { Text(stringResource(R.string.top_length)) }, text = { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text(tempLength.toInt().toString()) Slider( value = tempLength, onValueChange = { tempLength = it }, valueRange = 1f..100f, steps = 98 ) } }, confirmButton = { TextButton( onClick = { onLengthTopChange(tempLength.toInt().toString()) showTopLengthDialog = false } ) { Text(stringResource(R.string.save)) } } ) } var showProviderPriorityDialog by rememberSaveable { mutableStateOf(false) } if (showProviderPriorityDialog) { val currentOrder = LyricsProviderRegistry.deserializeProviderOrder(lyricsProviderOrder) val enabledProviders = setOf( "LrcLib".takeIf { enableLrclib }, "KuGou".takeIf { enableKugou }, "BetterLyrics".takeIf { enableBetterLyrics }, "SimpMusic".takeIf { enableSimpMusic }, "LyricsPlus".takeIf { enableLyricsPlus }, ).filterNotNull().toSet() val lyricsIcon = painterResource(R.drawable.lyrics) val draggableItems = remember { mutableStateListOf() } LaunchedEffect(currentOrder, enableLrclib, enableKugou, enableBetterLyrics, enableSimpMusic, enableLyricsPlus) { val orderedEnabledProviders = currentOrder.filter { it in enabledProviders } draggableItems.clear() draggableItems.addAll( orderedEnabledProviders.mapNotNull { providerName -> LyricsProviderRegistry.getProviderByName(providerName) ?: return@mapNotNull null DraggableLyricsProviderItem( id = providerName, name = providerDisplayNames[providerName] ?: providerName, icon = lyricsIcon, ) } ) } AlertDialog( onDismissRequest = { showProviderPriorityDialog = false }, title = { Text(stringResource(R.string.lyrics_provider_priority)) }, text = { Column( modifier = Modifier .fillMaxWidth() .height(300.dp) ) { Text( stringResource(R.string.lyrics_provider_priority_desc), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 8.dp), color = MaterialTheme.colorScheme.onSurfaceVariant ) DraggableLyricsProviderList( items = draggableItems, onItemsReordered = { reorderedItems -> val enabledOrder = reorderedItems.map { it.id } val disabledOrder = currentOrder.filter { it !in enabledProviders } onLyricsProviderOrderChange( LyricsProviderRegistry.serializeProviderOrder(enabledOrder + disabledOrder) ) }, modifier = Modifier .fillMaxWidth() .weight(1f) ) } }, confirmButton = { TextButton( onClick = { showProviderPriorityDialog = false } ) { Text(stringResource(R.string.close)) } } ) } Column( Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { Material3SettingsGroup( title = stringResource(R.string.general), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.language), title = { Text(stringResource(R.string.content_language)) }, description = { Text( LanguageCodeToName.getOrElse(contentLanguage) { stringResource(R.string.system_default) } ) }, onClick = { showContentLanguageDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.location_on), title = { Text(stringResource(R.string.content_country)) }, description = { Text( CountryCodeToName.getOrElse(contentCountry) { stringResource(R.string.system_default) } ) }, onClick = { showContentCountryDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.explicit), title = { Text(stringResource(R.string.hide_explicit)) }, trailingContent = { Switch( checked = hideExplicit, onCheckedChange = onHideExplicitChange, thumbContent = { Icon( painter = painterResource( id = if (hideExplicit) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onHideExplicitChange(!hideExplicit) } ), Material3SettingsItem( icon = painterResource(R.drawable.slow_motion_video), title = { Text(stringResource(R.string.hide_video_songs)) }, trailingContent = { Switch( checked = hideVideoSongs, onCheckedChange = onHideVideoSongsChange, thumbContent = { Icon( painter = painterResource( id = if (hideVideoSongs) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onHideVideoSongsChange(!hideVideoSongs) } ), Material3SettingsItem( icon = painterResource(R.drawable.hide_image), title = { Text(stringResource(R.string.hide_youtube_shorts)) }, trailingContent = { Switch( checked = hideYoutubeShorts, onCheckedChange = onHideYoutubeShortsChange, thumbContent = { Icon( painter = painterResource( id = if (hideYoutubeShorts) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onHideYoutubeShortsChange(!hideYoutubeShorts) } ) ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.artist_page_settings), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.info), title = { Text(stringResource(R.string.show_artist_description)) }, trailingContent = { Switch( checked = showArtistDescription, onCheckedChange = onShowArtistDescriptionChange, thumbContent = { Icon( painter = painterResource( id = if (showArtistDescription) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShowArtistDescriptionChange(!showArtistDescription) } ), Material3SettingsItem( icon = painterResource(R.drawable.person), title = { Text(stringResource(R.string.show_artist_subscriber_count)) }, trailingContent = { Switch( checked = showArtistSubscriberCount, onCheckedChange = onShowArtistSubscriberCountChange, thumbContent = { Icon( painter = painterResource( id = if (showArtistSubscriberCount) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShowArtistSubscriberCountChange(!showArtistSubscriberCount) } ), Material3SettingsItem( icon = painterResource(R.drawable.person), title = { Text(stringResource(R.string.show_artist_monthly_listeners)) }, trailingContent = { Switch( checked = showMonthlyListeners, onCheckedChange = onShowMonthlyListenersChange, thumbContent = { Icon( painter = painterResource( id = if (showMonthlyListeners) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShowMonthlyListenersChange(!showMonthlyListeners) } ) ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.app_language), items = listOf( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Material3SettingsItem( icon = painterResource(R.drawable.language), title = { Text(stringResource(R.string.app_language)) }, onClick = { context.startActivity( Intent( Settings.ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri() ) ) } ) } else { Material3SettingsItem( icon = painterResource(R.drawable.language), title = { Text(stringResource(R.string.app_language)) }, description = { Text( LanguageCodeToName.getOrElse(appLanguage) { stringResource(R.string.system_default) } ) }, onClick = { showAppLanguageDialog = true } ) } ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.proxy), items = buildList { add( Material3SettingsItem( icon = painterResource(R.drawable.wifi_proxy), title = { Text(stringResource(R.string.enable_proxy)) }, trailingContent = { Switch( checked = proxyEnabled, onCheckedChange = onProxyEnabledChange, thumbContent = { Icon( painter = painterResource( id = if (proxyEnabled) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onProxyEnabledChange(!proxyEnabled) } ) ) if (proxyEnabled) { add( Material3SettingsItem( icon = painterResource(R.drawable.settings), title = { Text(stringResource(R.string.config_proxy)) }, onClick = { showProxyConfigurationDialog = true } ) ) } } ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.lyrics), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.lyrics), title = { Text(stringResource(R.string.lyrics_provider_selection)) }, description = { Text(stringResource(R.string.lyrics_provider_selection_desc)) }, onClick = { showProviderSelectionDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.lyrics), title = { Text(stringResource(R.string.lyrics_provider_priority)) }, description = { Text(stringResource(R.string.lyrics_provider_priority_desc)) }, onClick = { showProviderPriorityDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.language_korean_latin), title = { Text(stringResource(R.string.lyrics_romanization)) }, onClick = { navController.navigate("settings/content/romanization") } ) ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = "Wrapped", items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.trending_up), title = { Text(stringResource(R.string.show_wrapped_card)) }, trailingContent = { Switch( checked = showWrappedCard, onCheckedChange = onShowWrappedCardChange, thumbContent = { Icon( painter = painterResource( id = if (showWrappedCard) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShowWrappedCardChange(!showWrappedCard) } ) ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.misc), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.shuffle), title = { Text(stringResource(R.string.randomize_home_order)) }, description = { Text(stringResource(R.string.randomize_home_order_desc)) }, trailingContent = { Switch( checked = randomizeHomeOrder, onCheckedChange = onRandomizeHomeOrderChange, thumbContent = { Icon( painter = painterResource( id = if (randomizeHomeOrder) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onRandomizeHomeOrderChange(!randomizeHomeOrder) } ), Material3SettingsItem( icon = painterResource(R.drawable.trending_up), title = { Text(stringResource(R.string.top_length)) }, description = { Text(lengthTop) }, onClick = { showTopLengthDialog = true } ), Material3SettingsItem( icon = painterResource(R.drawable.home_outlined), title = { Text(stringResource(R.string.set_quick_picks)) }, description = { Text( when (quickPicks) { QuickPicks.QUICK_PICKS -> stringResource(R.string.quick_picks) QuickPicks.LAST_LISTEN -> stringResource(R.string.last_song_listened) } ) }, onClick = { showQuickPicksDialog = true } ) ) ) Spacer(modifier = Modifier.height(16.dp)) } TopAppBar( title = { Text(stringResource(R.string.content)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/DiscordLoginScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import android.annotation.SuppressLint import android.os.Build import android.view.View import android.view.ViewGroup import android.webkit.JsResult import android.webkit.WebChromeClient 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.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.DiscordTokenKey import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import timber.log.Timber private const val JS_SNIPPET = "javascript:(function()%7Bvar%20i%3Ddocument.createElement('iframe')%3Bdocument.body.appendChild(i)%3Balert(i.contentWindow.localStorage.token.slice(1,-1))%7D)()" private const val MOTOROLA = "motorola" private const val SAMSUNG_USER_AGENT = "Mozilla/5.0 (Linux; Android 14; SM-S921U; Build/UP1A.231005.007) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36" @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 ) settings.javaScriptEnabled = true settings.domStorageEnabled = true // Fix for Motorola devices - UA parsing issue breaks Discord login // See: https://github.com/dead8309/Kizzy/issues/345#issuecomment-2699729072 if (Build.MANUFACTURER.equals(MOTOROLA, ignoreCase = true)) { settings.userAgentString = SAMSUNG_USER_AGENT } webViewClient = object : WebViewClient() { @Deprecated("Deprecated in Java") override fun shouldOverrideUrlLoading( view: WebView, url: String, ): Boolean { if (url.endsWith("/app")) { view.stopLoading() view.loadUrl(JS_SNIPPET) view.visibility = View.GONE } return false } } webChromeClient = object : WebChromeClient() { override fun onJsAlert( view: WebView, url: String, message: String, result: JsResult ): Boolean { Timber.d("Discord Token received") if (message.isNotBlank() && message != "null" && message != "undefined") { discordToken = message scope.launch(Dispatchers.Main) { navController.navigateUp() } } view.visibility = View.GONE 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() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/PlayerSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings 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.height import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.metrolist.music.BuildConfig import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.AudioNormalizationKey import com.metrolist.music.constants.AudioOffload import com.metrolist.music.constants.AudioQuality import com.metrolist.music.constants.AudioQualityKey import com.metrolist.music.constants.AutoDownloadOnLikeKey import com.metrolist.music.constants.CrossfadeDurationKey import com.metrolist.music.constants.CrossfadeEnabledKey import com.metrolist.music.constants.CrossfadeGaplessKey import com.metrolist.music.constants.AutoLoadMoreKey import com.metrolist.music.constants.AutoSkipNextOnErrorKey import com.metrolist.music.constants.DisableLoadMoreWhenRepeatAllKey import com.metrolist.music.constants.EnableGoogleCastKey import com.metrolist.music.constants.HistoryDuration import com.metrolist.music.constants.KeepScreenOn import com.metrolist.music.constants.PauseOnMute import com.metrolist.music.constants.PersistentQueueKey import com.metrolist.music.constants.PersistentShuffleAcrossQueuesKey import com.metrolist.music.constants.PreventDuplicateTracksInQueueKey import com.metrolist.music.constants.RememberShuffleAndRepeatKey import com.metrolist.music.constants.ResumeOnBluetoothConnectKey import com.metrolist.music.constants.SeekExtraSeconds import com.metrolist.music.constants.ShufflePlaylistFirstKey import com.metrolist.music.constants.SimilarContent import com.metrolist.music.constants.SkipSilenceInstantKey import com.metrolist.music.constants.SkipSilenceKey import com.metrolist.music.constants.StopMusicOnTaskClearKey import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.EnumDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference import kotlin.math.roundToInt import com.metrolist.music.ui.component.SleepTimerDialog import com.metrolist.music.constants.SleepTimerEnabledKey import com.metrolist.music.constants.SleepTimerRepeatKey import com.metrolist.music.constants.SleepTimerCustomDaysKey import com.metrolist.music.constants.SleepTimerEndTimeKey import com.metrolist.music.constants.SleepTimerStartTimeKey import com.metrolist.music.constants.SleepTimerDayTimesKey import com.metrolist.music.ui.component.decodeDayTimes import com.metrolist.music.ui.component.encodeDayTimes import com.metrolist.music.constants.SleepTimerFadeOutKey import com.metrolist.music.constants.SleepTimerStopAfterCurrentSongKey @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlayerSettings( navController: NavController ) { val (audioQuality, onAudioQualityChange) = rememberEnumPreference( AudioQualityKey, defaultValue = AudioQuality.AUTO ) val (crossfadeEnabled, onCrossfadeEnabledChange) = rememberPreference( CrossfadeEnabledKey, defaultValue = false ) val (crossfadeDuration, onCrossfadeDurationChange) = rememberPreference( CrossfadeDurationKey, defaultValue = 5f ) val (crossfadeGapless, onCrossfadeGaplessChange) = rememberPreference( CrossfadeGaplessKey, defaultValue = true ) val (persistentQueue, onPersistentQueueChange) = rememberPreference( PersistentQueueKey, defaultValue = true ) val (skipSilence, onSkipSilenceChange) = rememberPreference( SkipSilenceKey, defaultValue = false ) val (skipSilenceInstant, onSkipSilenceInstantChange) = rememberPreference( SkipSilenceInstantKey, defaultValue = false ) val (audioNormalization, onAudioNormalizationChange) = rememberPreference( AudioNormalizationKey, defaultValue = true ) val (audioOffload, onAudioOffloadChange) = rememberPreference( key = AudioOffload, defaultValue = false ) val (enableGoogleCast, onEnableGoogleCastChange) = rememberPreference( key = EnableGoogleCastKey, defaultValue = true ) val (seekExtraSeconds, onSeekExtraSeconds) = rememberPreference( SeekExtraSeconds, defaultValue = false ) val (autoLoadMore, onAutoLoadMoreChange) = rememberPreference( AutoLoadMoreKey, defaultValue = true ) val (disableLoadMoreWhenRepeatAll, onDisableLoadMoreWhenRepeatAllChange) = rememberPreference( DisableLoadMoreWhenRepeatAllKey, defaultValue = false ) val (autoDownloadOnLike, onAutoDownloadOnLikeChange) = rememberPreference( AutoDownloadOnLikeKey, defaultValue = false ) val (similarContentEnabled, similarContentEnabledChange) = rememberPreference( key = SimilarContent, defaultValue = true ) val (autoSkipNextOnError, onAutoSkipNextOnErrorChange) = rememberPreference( AutoSkipNextOnErrorKey, defaultValue = false ) val (persistentShuffleAcrossQueues, onPersistentShuffleAcrossQueuesChange) = rememberPreference( PersistentShuffleAcrossQueuesKey, defaultValue = false ) val (rememberShuffleAndRepeat, onRememberShuffleAndRepeatChange) = rememberPreference( RememberShuffleAndRepeatKey, defaultValue = true ) val (shufflePlaylistFirst, onShufflePlaylistFirstChange) = rememberPreference( ShufflePlaylistFirstKey, defaultValue = false ) val (preventDuplicateTracksInQueue, onPreventDuplicateTracksInQueueChange) = rememberPreference( PreventDuplicateTracksInQueueKey, defaultValue = false ) val (stopMusicOnTaskClear, onStopMusicOnTaskClearChange) = rememberPreference( StopMusicOnTaskClearKey, defaultValue = false ) val (pauseOnMute, onPauseOnMuteChange) = rememberPreference( PauseOnMute, defaultValue = false ) val (resumeOnBluetoothConnect, onResumeOnBluetoothConnectChange) = rememberPreference( ResumeOnBluetoothConnectKey, defaultValue = false ) val (keepScreenOn, onKeepScreenOnChange) = rememberPreference( KeepScreenOn, defaultValue = false ) val (historyDuration, onHistoryDurationChange) = rememberPreference( HistoryDuration, defaultValue = 30f ) var showAudioQualityDialog by remember { mutableStateOf(false) } if (showAudioQualityDialog) { EnumDialog( onDismiss = { showAudioQualityDialog = false }, onSelect = { onAudioQualityChange(it) showAudioQualityDialog = false }, title = stringResource(R.string.audio_quality), current = audioQuality, values = AudioQuality.values().toList(), 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) } } ) } Column( Modifier .windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom ) ) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { var showCrossfadeBetaDialog by remember { mutableStateOf(false) } if (showCrossfadeBetaDialog) { DefaultDialog( onDismiss = { showCrossfadeBetaDialog = false }, title = { Text(stringResource(R.string.crossfade_beta_title)) }, buttons = { TextButton(onClick = { showCrossfadeBetaDialog = false }) { Text(stringResource(R.string.cancel)) } TextButton(onClick = { showCrossfadeBetaDialog = false onCrossfadeEnabledChange(true) }) { Text(stringResource(R.string.enable)) } } ) { Text(stringResource(R.string.crossfade_beta_message)) } } Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Top ) ) ) Material3SettingsGroup( title = stringResource(R.string.player), items = buildList { add(Material3SettingsItem( icon = painterResource(R.drawable.graphic_eq), title = { Text(stringResource(R.string.audio_quality)) }, description = { Text( when (audioQuality) { AudioQuality.AUTO -> stringResource(R.string.audio_quality_auto) AudioQuality.HIGH -> stringResource(R.string.audio_quality_high) AudioQuality.LOW -> stringResource(R.string.audio_quality_low) } ) }, onClick = { showAudioQualityDialog = true } )) add(Material3SettingsItem( icon = painterResource(R.drawable.linear_scale), title = { Text(stringResource(R.string.crossfade)) }, description = { Text(stringResource(R.string.crossfade_desc)) }, showBadge = true, trailingContent = { Switch( checked = crossfadeEnabled, onCheckedChange = { if (!crossfadeEnabled) { showCrossfadeBetaDialog = true } else { onCrossfadeEnabledChange(false) } }, thumbContent = { Icon( painter = painterResource( id = if (crossfadeEnabled) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { if (!crossfadeEnabled) { showCrossfadeBetaDialog = true } else { onCrossfadeEnabledChange(false) } } )) if (crossfadeEnabled) { add(Material3SettingsItem( icon = painterResource(R.drawable.timer), title = { Text(stringResource(R.string.crossfade_duration)) }, description = { Column { Text(pluralStringResource(R.plurals.seconds, crossfadeDuration.toInt(), crossfadeDuration.toInt())) Slider( value = crossfadeDuration, onValueChange = onCrossfadeDurationChange, valueRange = 1f..15f, steps = 14 ) } } )) add(Material3SettingsItem( icon = painterResource(R.drawable.album), title = { Text(stringResource(R.string.crossfade_gapless)) }, description = { Text(stringResource(R.string.crossfade_gapless_desc)) }, trailingContent = { Switch( checked = crossfadeGapless, onCheckedChange = onCrossfadeGaplessChange, thumbContent = { Icon( painter = painterResource( id = if (crossfadeGapless) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onCrossfadeGaplessChange(!crossfadeGapless) } )) } add(Material3SettingsItem( icon = painterResource(R.drawable.history), title = { Text(stringResource(R.string.history_duration)) }, description = { Column { Text(historyDuration.roundToInt().toString()) Slider( value = historyDuration, onValueChange = onHistoryDurationChange, valueRange = 1f..100f ) } } )) add(Material3SettingsItem( icon = painterResource(R.drawable.fast_forward), title = { Text(stringResource(R.string.skip_silence)) }, description = { Text(stringResource(R.string.skip_silence_desc)) }, trailingContent = { Switch( checked = skipSilence, onCheckedChange = onSkipSilenceChange, thumbContent = { Icon( painter = painterResource( id = if (skipSilence) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onSkipSilenceChange(!skipSilence) } )) add(Material3SettingsItem( icon = painterResource(R.drawable.skip_next), title = { Text(stringResource(R.string.skip_silence_instant)) }, description = { Text(stringResource(R.string.skip_silence_instant_desc)) }, trailingContent = { Switch( checked = skipSilenceInstant, onCheckedChange = { onSkipSilenceInstantChange(it) }, enabled = skipSilence, thumbContent = { Icon( painter = painterResource( id = if (skipSilenceInstant) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { if (skipSilence) onSkipSilenceInstantChange(!skipSilenceInstant) } )) add(Material3SettingsItem( icon = painterResource(R.drawable.volume_up), title = { Text(stringResource(R.string.audio_normalization)) }, trailingContent = { Switch( checked = audioNormalization, onCheckedChange = onAudioNormalizationChange, thumbContent = { Icon( painter = painterResource( id = if (audioNormalization) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onAudioNormalizationChange(!audioNormalization) } )) add(Material3SettingsItem( icon = painterResource(R.drawable.graphic_eq), title = { Text(stringResource(R.string.audio_offload)) }, description = { Text( if (crossfadeEnabled) stringResource(R.string.audio_offload_disabled_by_crossfade) else stringResource(R.string.audio_offload_description) ) }, trailingContent = { Switch( checked = if (crossfadeEnabled) false else audioOffload, onCheckedChange = onAudioOffloadChange, enabled = !crossfadeEnabled, thumbContent = { Icon( painter = painterResource( id = if (!crossfadeEnabled && audioOffload) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { if (!crossfadeEnabled) onAudioOffloadChange(!audioOffload) } )) // Only show Cast setting in GMS builds (not in F-Droid/FOSS) if (BuildConfig.CAST_AVAILABLE) { add(Material3SettingsItem( icon = painterResource(R.drawable.cast), title = { Text(stringResource(R.string.google_cast)) }, description = { Text(stringResource(R.string.google_cast_description)) }, trailingContent = { Switch( checked = enableGoogleCast, onCheckedChange = onEnableGoogleCastChange, thumbContent = { Icon( painter = painterResource( id = if (enableGoogleCast) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onEnableGoogleCastChange(!enableGoogleCast) } )) } add(Material3SettingsItem( icon = painterResource(R.drawable.arrow_forward), title = { Text(stringResource(R.string.seek_seconds_addup)) }, description = { Text(stringResource(R.string.seek_seconds_addup_description)) }, trailingContent = { Switch( checked = seekExtraSeconds, onCheckedChange = onSeekExtraSeconds, thumbContent = { Icon( painter = painterResource( id = if (seekExtraSeconds) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onSeekExtraSeconds(!seekExtraSeconds) } )) } ) Spacer(modifier = Modifier.height(27.dp)) var showSleepTimerDialog by remember { mutableStateOf(false) } val (sleepTimerEnabled, onSleepTimerEnabledChange) = rememberPreference( SleepTimerEnabledKey, defaultValue = false ) val (sleepTimerRepeat, onSleepTimerRepeatChange) = rememberPreference( SleepTimerRepeatKey, defaultValue = "daily" ) val (sleepTimerStartTime, onSleepTimerStartTimeChange) = rememberPreference( SleepTimerStartTimeKey, defaultValue = "22:00" ) val (sleepTimerEndTime, onSleepTimerEndTimeChange) = rememberPreference( SleepTimerEndTimeKey, defaultValue = "06:00" ) val (sleepTimerCustomDays, onSleepTimerCustomDaysChange) = rememberPreference( SleepTimerCustomDaysKey, defaultValue = "0,1,2,3,4" ) // Per-day time ranges used in custom mode val (sleepTimerDayTimes, onSleepTimerDayTimesChange) = rememberPreference( SleepTimerDayTimesKey, defaultValue = "" ) val (sleepTimerStopAfterCurrentSong, onSleepTimerStopAfterCurrentSongChange) = rememberPreference ( SleepTimerStopAfterCurrentSongKey, defaultValue = false) val (sleepTimerFadeOut, onSleepTimerFadeOutChange) = rememberPreference( SleepTimerFadeOutKey, false ) if (showSleepTimerDialog) { val customDays = sleepTimerCustomDays.split(",").mapNotNull { it.toIntOrNull() } val dayTimesMap = decodeDayTimes(sleepTimerDayTimes) SleepTimerDialog( isVisible = true, onDismiss = { showSleepTimerDialog = false }, onConfirm = { repeat, startTime, endTime, days, dayTimes -> onSleepTimerRepeatChange(repeat) onSleepTimerStartTimeChange(startTime) onSleepTimerEndTimeChange(endTime) onSleepTimerCustomDaysChange(days?.joinToString(",") ?: "0,1,2,3,4") onSleepTimerDayTimesChange(encodeDayTimes(dayTimes)) showSleepTimerDialog = false }, initialRepeat = sleepTimerRepeat, initialStartTime = sleepTimerStartTime, initialEndTime = sleepTimerEndTime, initialCustomDays = customDays, initialDayTimes = dayTimesMap ) } Material3SettingsGroup( title = stringResource(R.string.sleep_timer), items = buildList { add( Material3SettingsItem( icon = painterResource(R.drawable.time_auto), title = { Text(stringResource(R.string.enable_automatic_sleeptimer)) }, description = { Text(stringResource(R.string.sleeptimer_description)) }, trailingContent = { Switch( checked = sleepTimerEnabled, onCheckedChange = onSleepTimerEnabledChange, thumbContent = { Icon( painter = painterResource( id = if (sleepTimerEnabled) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onSleepTimerEnabledChange(!sleepTimerEnabled) } ) ) add( Material3SettingsItem( icon = painterResource(R.drawable.baseline_event_repeat_24), title = { Text(stringResource(R.string.sleep_timer_repeat)) }, description = { Text( stringResource(R.string.sleep_timer_repeat_description) ) }, trailingContent = { Switch( checked = sleepTimerEnabled, onCheckedChange = {showSleepTimerDialog = true}, thumbContent = { Icon( painter = painterResource( id = if (sleepTimerEnabled) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { showSleepTimerDialog = true } ) ) add( Material3SettingsItem( icon = painterResource(R.drawable.more_time), title = { Text(stringResource(R.string.sleep_timer_stop_after_current_song_title)) }, description = { Text(stringResource(R.string.sleep_timer_stop_after_current_song_description)) }, trailingContent = { Switch( checked = sleepTimerStopAfterCurrentSong, onCheckedChange = onSleepTimerStopAfterCurrentSongChange, thumbContent = { Icon( painter = painterResource( id = if (sleepTimerStopAfterCurrentSong) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onSleepTimerStopAfterCurrentSongChange(!sleepTimerStopAfterCurrentSong) } ) ) add( Material3SettingsItem( icon = painterResource(R.drawable.timer_arrow_down), title = { Text(stringResource(R.string.sleep_timer_fade_out_title)) }, description = { Text(stringResource(R.string.sleep_timer_fade_out_description)) }, trailingContent = { Switch( checked = sleepTimerFadeOut, onCheckedChange = onSleepTimerFadeOutChange, thumbContent = { Icon( painter = painterResource( id = if (sleepTimerFadeOut) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onSleepTimerFadeOutChange(!sleepTimerFadeOut) } ) ) } ) AlarmSettingsSection(showTitle = false) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.queue), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.queue_music), title = { Text(stringResource(R.string.persistent_queue)) }, description = { Text(stringResource(R.string.persistent_queue_desc)) }, trailingContent = { Switch( checked = persistentQueue, onCheckedChange = onPersistentQueueChange, thumbContent = { Icon( painter = painterResource( id = if (persistentQueue) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onPersistentQueueChange(!persistentQueue) } ), Material3SettingsItem( icon = painterResource(R.drawable.playlist_add), title = { Text(stringResource(R.string.auto_load_more)) }, description = { Text(stringResource(R.string.auto_load_more_desc)) }, trailingContent = { Switch( checked = autoLoadMore, onCheckedChange = onAutoLoadMoreChange, thumbContent = { Icon( painter = painterResource( id = if (autoLoadMore) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onAutoLoadMoreChange(!autoLoadMore) } ), Material3SettingsItem( icon = painterResource(R.drawable.repeat), title = { Text(stringResource(R.string.disable_load_more_when_repeat_all)) }, description = { Text(stringResource(R.string.disable_load_more_when_repeat_all_desc)) }, trailingContent = { Switch( checked = disableLoadMoreWhenRepeatAll, onCheckedChange = onDisableLoadMoreWhenRepeatAllChange, thumbContent = { Icon( painter = painterResource( id = if (disableLoadMoreWhenRepeatAll) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onDisableLoadMoreWhenRepeatAllChange(!disableLoadMoreWhenRepeatAll) } ), Material3SettingsItem( icon = painterResource(R.drawable.download), title = { Text(stringResource(R.string.auto_download_on_like)) }, description = { Text(stringResource(R.string.auto_download_on_like_desc)) }, trailingContent = { Switch( checked = autoDownloadOnLike, onCheckedChange = onAutoDownloadOnLikeChange, thumbContent = { Icon( painter = painterResource( id = if (autoDownloadOnLike) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onAutoDownloadOnLikeChange(!autoDownloadOnLike) } ), Material3SettingsItem( icon = painterResource(R.drawable.similar), title = { Text(stringResource(R.string.enable_similar_content)) }, description = { Text(stringResource(R.string.similar_content_desc)) }, trailingContent = { Switch( checked = similarContentEnabled, onCheckedChange = similarContentEnabledChange, thumbContent = { Icon( painter = painterResource( id = if (similarContentEnabled) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { similarContentEnabledChange(!similarContentEnabled) } ), Material3SettingsItem( icon = painterResource(R.drawable.shuffle), title = { Text(stringResource(R.string.persistent_shuffle_title)) }, description = { Text(stringResource(R.string.persistent_shuffle_desc)) }, trailingContent = { Switch( checked = persistentShuffleAcrossQueues, onCheckedChange = onPersistentShuffleAcrossQueuesChange, thumbContent = { Icon( painter = painterResource( id = if (persistentShuffleAcrossQueues) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onPersistentShuffleAcrossQueuesChange(!persistentShuffleAcrossQueues) } ), Material3SettingsItem( icon = painterResource(R.drawable.shuffle), title = { Text(stringResource(R.string.remember_shuffle_and_repeat)) }, description = { Text(stringResource(R.string.remember_shuffle_and_repeat_desc)) }, trailingContent = { Switch( checked = rememberShuffleAndRepeat, onCheckedChange = onRememberShuffleAndRepeatChange, thumbContent = { Icon( painter = painterResource( id = if (rememberShuffleAndRepeat) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onRememberShuffleAndRepeatChange(!rememberShuffleAndRepeat) } ), Material3SettingsItem( icon = painterResource(R.drawable.shuffle), title = { Text(stringResource(R.string.shuffle_playlist_first)) }, description = { Text(stringResource(R.string.shuffle_playlist_first_desc)) }, trailingContent = { Switch( checked = shufflePlaylistFirst, onCheckedChange = onShufflePlaylistFirstChange, thumbContent = { Icon( painter = painterResource( id = if (shufflePlaylistFirst) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onShufflePlaylistFirstChange(!shufflePlaylistFirst) } ), Material3SettingsItem( icon = painterResource(R.drawable.queue_music), title = { Text(stringResource(R.string.prevent_duplicate_tracks_in_queue)) }, description = { Text(stringResource(R.string.prevent_duplicate_tracks_in_queue_desc)) }, trailingContent = { Switch( checked = preventDuplicateTracksInQueue, onCheckedChange = onPreventDuplicateTracksInQueueChange, thumbContent = { Icon( painter = painterResource( id = if (preventDuplicateTracksInQueue) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onPreventDuplicateTracksInQueueChange(!preventDuplicateTracksInQueue) } ), Material3SettingsItem( icon = painterResource(R.drawable.skip_next), title = { Text(stringResource(R.string.auto_skip_next_on_error)) }, description = { Text(stringResource(R.string.auto_skip_next_on_error_desc)) }, trailingContent = { Switch( checked = autoSkipNextOnError, onCheckedChange = onAutoSkipNextOnErrorChange, thumbContent = { Icon( painter = painterResource( id = if (autoSkipNextOnError) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onAutoSkipNextOnErrorChange(!autoSkipNextOnError) } ) ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.misc), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.clear_all), title = { Text(stringResource(R.string.stop_music_on_task_clear)) }, trailingContent = { Switch( checked = stopMusicOnTaskClear, onCheckedChange = onStopMusicOnTaskClearChange, thumbContent = { Icon( painter = painterResource( id = if (stopMusicOnTaskClear) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onStopMusicOnTaskClearChange(!stopMusicOnTaskClear) } ), Material3SettingsItem( icon = painterResource(R.drawable.volume_off_pause), title = { Text(stringResource(R.string.pause_music_when_media_is_muted)) }, trailingContent = { Switch( checked = pauseOnMute, onCheckedChange = onPauseOnMuteChange, thumbContent = { Icon( painter = painterResource( id = if (pauseOnMute) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onPauseOnMuteChange(!pauseOnMute) } ), Material3SettingsItem( icon = painterResource(R.drawable.bluetooth), title = { Text(stringResource(R.string.resume_on_bluetooth_connect)) }, trailingContent = { Switch( checked = resumeOnBluetoothConnect, onCheckedChange = onResumeOnBluetoothConnectChange, thumbContent = { Icon( painter = painterResource( id = if (resumeOnBluetoothConnect) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onResumeOnBluetoothConnectChange(!resumeOnBluetoothConnect) } ), Material3SettingsItem( icon = painterResource(R.drawable.screenshot), title = { Text(stringResource(R.string.keep_screen_on_when_player_is_expanded)) }, trailingContent = { Switch( checked = keepScreenOn, onCheckedChange = onKeepScreenOnChange, thumbContent = { Icon( painter = painterResource( id = if (keepScreenOn) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onKeepScreenOnChange(!keepScreenOn) } ) ) ) Spacer(modifier = Modifier.height(16.dp)) } TopAppBar( title = { Text(stringResource(R.string.player_and_audio)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null ) } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/PrivacySettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.unit.dp import androidx.navigation.NavController import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.DisableScreenshotKey import com.metrolist.music.constants.PauseListenHistoryKey import com.metrolist.music.constants.PauseSearchHistoryKey import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberPreference @OptIn(ExperimentalMaterial3Api::class) @Composable fun PrivacySettings( navController: NavController ) { 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) } if (showClearListenHistoryDialog) { DefaultDialog( onDismiss = { showClearListenHistoryDialog = false }, content = { Text( text = stringResource(R.string.clear_listen_history_confirm), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(horizontal = 18.dp), ) }, buttons = { TextButton( onClick = { showClearListenHistoryDialog = false }, ) { Text(text = stringResource(android.R.string.cancel)) } TextButton( onClick = { showClearListenHistoryDialog = false database.query { clearListenHistory() } }, ) { Text(text = stringResource(android.R.string.ok)) } }, ) } var showClearSearchHistoryDialog by remember { mutableStateOf(false) } if (showClearSearchHistoryDialog) { DefaultDialog( onDismiss = { showClearSearchHistoryDialog = false }, content = { Text( text = stringResource(R.string.clear_search_history_confirm), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(horizontal = 18.dp), ) }, buttons = { TextButton( onClick = { showClearSearchHistoryDialog = false }, ) { Text(text = stringResource(android.R.string.cancel)) } TextButton( onClick = { showClearSearchHistoryDialog = false database.query { clearSearchHistory() } }, ) { Text(text = stringResource(android.R.string.ok)) } }, ) } Column( Modifier .windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom ) ) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Top ) ) ) Material3SettingsGroup( title = stringResource(R.string.listen_history), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.history), title = { Text(stringResource(R.string.pause_listen_history)) }, trailingContent = { Switch( checked = pauseListenHistory, onCheckedChange = onPauseListenHistoryChange, thumbContent = { Icon( painter = painterResource( id = if (pauseListenHistory) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(androidx.compose.material3.SwitchDefaults.IconSize) ) } ) }, onClick = { onPauseListenHistoryChange(!pauseListenHistory) } ), Material3SettingsItem( icon = painterResource(R.drawable.delete_history), title = { Text(stringResource(R.string.clear_listen_history)) }, onClick = { showClearListenHistoryDialog = true } ) ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.search_history), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.search_off), title = { Text(stringResource(R.string.pause_search_history)) }, trailingContent = { Switch( checked = pauseSearchHistory, onCheckedChange = onPauseSearchHistoryChange, thumbContent = { Icon( painter = painterResource( id = if (pauseSearchHistory) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(androidx.compose.material3.SwitchDefaults.IconSize) ) } ) }, onClick = { onPauseSearchHistoryChange(!pauseSearchHistory) } ), Material3SettingsItem( icon = painterResource(R.drawable.clear_all), title = { Text(stringResource(R.string.clear_search_history)) }, onClick = { showClearSearchHistoryDialog = true } ) ) ) Spacer(modifier = Modifier.height(27.dp)) Material3SettingsGroup( title = stringResource(R.string.misc), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.screenshot), title = { Text(stringResource(R.string.disable_screenshot)) }, description = { Text(stringResource(R.string.disable_screenshot_desc)) }, trailingContent = { Switch( checked = disableScreenshot, onCheckedChange = onDisableScreenshotChange, thumbContent = { Icon( painter = painterResource( id = if (disableScreenshot) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(androidx.compose.material3.SwitchDefaults.IconSize) ) } ) }, onClick = { onDisableScreenshotChange(!disableScreenshot) } ) ) ) Spacer(modifier = Modifier.height(16.dp)) } TopAppBar( title = { Text(stringResource(R.string.privacy)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/RomanizationSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height 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.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TriStateCheckbox import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.LyricsRomanizeAsMainKey import com.metrolist.music.constants.LyricsRomanizeCyrillicByLineKey import com.metrolist.music.constants.LyricsRomanizeList import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberPreference val defaultList = mutableListOf( "Japanese" to true, "Korean" to true, "Chinese" to true, "Hindi" to true, "Punjabi" to true, "Russian" to true, "Ukrainian" to true, "Serbian" to true, "Bulgarian" to true, "Belarusian" to true, "Kyrgyz" to true, "Macedonian" to true, ) @OptIn(ExperimentalMaterial3Api::class) @Composable fun RomanizationSettings( navController: NavController ) { val (pref, prefValue) = rememberPreference(LyricsRomanizeList, "") val initialList = remember(pref) { if (pref.isEmpty()) defaultList else { val savedMap = pref.split(",").associate { entry -> val (lang, checked) = entry.split(":") lang to checked.toBoolean() } defaultList.map { (lang, defaultChecked) -> Pair(lang, savedMap[lang] ?: defaultChecked) } } } val states = remember(initialList) { mutableStateListOf(*initialList.toTypedArray()) } val parentState = when { states.all { it.component2() } -> ToggleableState.On states.none { it.component2() } -> ToggleableState.Off else -> ToggleableState.Indeterminate } val (lyricsRomanizeAsMain, onLyricsRomanizeAsMainChange) = rememberPreference( LyricsRomanizeAsMainKey, defaultValue = false ) val (lyricsRomanizeCyrillicByLine, onLyricsRomanizeCyrillicByLineChange) = rememberPreference( LyricsRomanizeCyrillicByLineKey, defaultValue = false ) val checkboxesList: MutableList = mutableListOf() Column( Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { Material3SettingsGroup( title = stringResource(R.string.options), items = listOf( Material3SettingsItem( title = { Text(stringResource(R.string.lyrics_romanize_as_main)) }, trailingContent = { Switch( checked = lyricsRomanizeAsMain, onCheckedChange = onLyricsRomanizeAsMainChange, ) }, icon = painterResource(R.drawable.queue_music) ), Material3SettingsItem( title = { Text(stringResource(R.string.line_by_line_option_title)) }, trailingContent = { Switch( checked = lyricsRomanizeCyrillicByLine, onCheckedChange = onLyricsRomanizeCyrillicByLineChange, ) }, icon = painterResource(R.drawable.info) ) ) ) Spacer(modifier = Modifier.height(8.dp)) checkboxesList += Material3SettingsItem( title = { Text("Play all") }, trailingContent = { TriStateCheckbox( state = parentState, onClick = { val newState = parentState != ToggleableState.On states.forEachIndexed { index, (language, _) -> states[index] = Pair(language, newState) } prefValue(states.joinToString(",") { (lang, c) -> "$lang:$c" }) } ) }, icon = painterResource(R.drawable.info) ) states.forEachIndexed { index, (language, checked) -> checkboxesList += Material3SettingsItem( title = { Text(language) }, trailingContent = { Checkbox( checked = checked, onCheckedChange = { isChecked -> states[index] = Pair(language, isChecked) prefValue(states.joinToString(",") { (lang, c) -> "$lang:$c" }) } ) }, icon = painterResource(R.drawable.language) ) } Material3SettingsGroup( title = stringResource(R.string.content_language), items = checkboxesList ) } TopAppBar( title = { Text(stringResource(R.string.lyrics_romanize_title)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/SettingsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import android.content.ActivityNotFoundException import android.content.Intent import android.os.Build import android.provider.Settings import android.widget.Toast 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.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.navigation.NavController import com.metrolist.music.BuildConfig import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.component.ReleaseNotesCard import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.Updater import androidx.compose.runtime.remember @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( navController: NavController, latestVersionName: String, ) { val uriHandler = LocalUriHandler.current val context = LocalContext.current val isAndroid12OrLater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val hasAndroidAuto = remember { try { context.packageManager.getPackageInfo( "com.google.android.projection.gearhead", 0 ) true } catch (e: Exception) { false } } Column( Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Top ) ) ) // User Interface Section Material3SettingsGroup( title = stringResource(R.string.settings_section_ui), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.palette), title = { Text(stringResource(R.string.appearance)) }, onClick = { navController.navigate("settings/appearance") } ) ) ) Spacer(modifier = Modifier.height(16.dp)) // Player & Content Section (moved up and combined with content) Material3SettingsGroup( title = stringResource(R.string.settings_section_player_content), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.play), title = { Text(stringResource(R.string.player_and_audio)) }, onClick = { navController.navigate("settings/player") } ), Material3SettingsItem( icon = painterResource(R.drawable.language), title = { Text(stringResource(R.string.content)) }, onClick = { navController.navigate("settings/content") } ), Material3SettingsItem( icon = painterResource(R.drawable.translate), title = { Text(stringResource(R.string.ai_lyrics_translation)) }, onClick = { navController.navigate("settings/ai") } ) ) ) Spacer(modifier = Modifier.height(16.dp)) // Android Auto Section — only shown if Android Auto is installed if (hasAndroidAuto) { Material3SettingsGroup( title = "Android Auto", items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.ic_android_auto), title = { Text(stringResource(R.string.android_auto)) }, onClick = { navController.navigate("settings/android_auto") } ) ) ) Spacer(modifier = Modifier.height(16.dp)) } // Privacy & Security Section Material3SettingsGroup( title = stringResource(R.string.settings_section_privacy), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.security), title = { Text(stringResource(R.string.privacy)) }, onClick = { navController.navigate("settings/privacy") } ) ) ) Spacer(modifier = Modifier.height(16.dp)) // Storage & Data Section Material3SettingsGroup( title = stringResource(R.string.settings_section_storage), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.storage), title = { Text(stringResource(R.string.storage)) }, onClick = { navController.navigate("settings/storage") } ), Material3SettingsItem( icon = painterResource(R.drawable.restore), title = { Text(stringResource(R.string.backup_restore)) }, onClick = { navController.navigate("settings/backup_restore") } ) ) ) Spacer(modifier = Modifier.height(16.dp)) // System & About Section Material3SettingsGroup( title = stringResource(R.string.settings_section_system), items = buildList { if (isAndroid12OrLater) { add( Material3SettingsItem( icon = painterResource(R.drawable.link), title = { Text(stringResource(R.string.default_links)) }, onClick = { try { val intent = Intent( Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, "package:${context.packageName}".toUri() ) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } catch (e: Exception) { when (e) { is ActivityNotFoundException -> { Toast.makeText( context, R.string.open_app_settings_error, Toast.LENGTH_LONG ).show() } is SecurityException -> { Toast.makeText( context, R.string.open_app_settings_error, Toast.LENGTH_LONG ).show() } else -> { Toast.makeText( context, R.string.open_app_settings_error, Toast.LENGTH_LONG ).show() } } } } ) ) } if (BuildConfig.UPDATER_AVAILABLE) { add( Material3SettingsItem( icon = painterResource(R.drawable.update), title = { Text(stringResource(R.string.updater)) }, onClick = { navController.navigate("settings/updater") } ) ) } val showChangelog = com.metrolist.music.LocalChangelogState.current add( Material3SettingsItem( icon = painterResource(R.drawable.newspaper), title = { Text(stringResource(R.string.changelog)) }, onClick = { showChangelog.value = true } ) ) add( Material3SettingsItem( icon = painterResource(R.drawable.info), title = { Text(stringResource(R.string.about)) }, onClick = { navController.navigate("settings/about") } ) ) if (BuildConfig.UPDATER_AVAILABLE && latestVersionName != BuildConfig.VERSION_NAME) { val releaseInfo = Updater.getCachedLatestRelease() val downloadUrl = releaseInfo?.let { Updater.getDownloadUrlForCurrentVariant(it) } if (downloadUrl != null) { add( Material3SettingsItem( icon = painterResource(R.drawable.update), title = { Text( text = stringResource(R.string.new_version_available), ) }, description = { Text( text = latestVersionName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) }, showBadge = true, onClick = { uriHandler.openUri(downloadUrl) } ) ) } } } ) if (BuildConfig.UPDATER_AVAILABLE && latestVersionName != BuildConfig.VERSION_NAME) { Spacer(modifier = Modifier.height(16.dp)) ReleaseNotesCard() } Spacer(modifier = Modifier.height(16.dp)) } TopAppBar( title = { Text(stringResource(R.string.settings)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null ) } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/StorageSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.size 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.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.StrokeCap 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.navigation.NavController import coil3.SingletonImageLoader import coil3.annotation.DelicateCoilApi import coil3.annotation.ExperimentalCoilApi import coil3.imageLoader import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.EnableSongCacheKey import com.metrolist.music.constants.MaxImageCacheSizeKey import com.metrolist.music.constants.MaxSongCacheSizeKey import com.metrolist.music.extensions.tryOrNull import com.metrolist.music.ui.component.ActionPromptDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.ui.utils.formatFileSize import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import okio.ByteString.Companion.encodeUtf8 import java.io.File import kotlin.math.roundToInt @OptIn(ExperimentalCoilApi::class, ExperimentalMaterial3Api::class, DelicateCoilApi::class) @Composable fun StorageSettings( navController: NavController ) { val context = LocalContext.current val database = LocalDatabase.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 songCacheString = stringResource(R.string.song_cache).lowercase() val imageCacheString = stringResource(R.string.image_cache).lowercase() val (maxImageCacheSize, onMaxImageCacheSizeChange) = rememberPreference( key = MaxImageCacheSizeKey, defaultValue = 512 ) val (maxSongCacheSize, onMaxSongCacheSizeChange) = rememberPreference( key = MaxSongCacheSizeKey, defaultValue = 1024 ) val (enableSongCache, onEnableSongCacheChange) = rememberPreference( key = EnableSongCacheKey, defaultValue = true ) var clearDownloads by remember { mutableStateOf(false) } var clearCacheDialog by remember { mutableStateOf(false) } var clearImageCacheDialog by remember { mutableStateOf(false) } // State for the confirmation dialog var showCacheWarningDialog by remember { mutableStateOf(false) } var cacheType by remember { mutableStateOf("") } var cacheUsage by remember { androidx.compose.runtime.mutableLongStateOf(0L) } var onConfirmAction by remember { mutableStateOf<() -> Unit>({}) } var imageCacheSize by remember { androidx.compose.runtime.mutableLongStateOf(imageDiskCache.size) } var playerCacheSize by remember { androidx.compose.runtime.mutableLongStateOf(tryOrNull { playerCache.cacheSpace } ?: 0) } var downloadCacheSize by remember { mutableLongStateOf(tryOrNull { downloadCache.cacheSpace } ?: 0) } val imageCacheProgress by animateFloatAsState( targetValue = (imageCacheSize.toFloat() / (maxImageCacheSize * 1024 * 1024L)).coerceIn( 0f, 1f, ), label = "imageCacheProgress", ) val playerCacheProgress by animateFloatAsState( targetValue = (playerCacheSize.toFloat() / (maxSongCacheSize * 1024 * 1024L)).coerceIn( 0f, 1f, ), label = "playerCacheProgress", ) LaunchedEffect(maxImageCacheSize) { SingletonImageLoader.reset() if (maxImageCacheSize == 0) { coroutineScope.launch(Dispatchers.IO) { imageDiskCache.clear() } } } LaunchedEffect(maxSongCacheSize) { if (maxSongCacheSize == 0) { coroutineScope.launch(Dispatchers.IO) { playerCache.keys.forEach { key -> playerCache.removeResource(key) } } } } 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 } } if (clearDownloads) { ActionPromptDialog( title = stringResource(R.string.clear_all_downloads), onDismiss = { clearDownloads = false }, onConfirm = { coroutineScope.launch(Dispatchers.IO) { downloadCache.keys.forEach { key -> downloadCache.removeResource(key) } } clearDownloads = false }, onCancel = { clearDownloads = false }, content = { Text(text = stringResource(R.string.clear_downloads_dialog)) }, ) } if (clearCacheDialog) { ActionPromptDialog( title = stringResource(R.string.clear_song_cache), onDismiss = { clearCacheDialog = false }, onConfirm = { coroutineScope.launch(Dispatchers.IO) { playerCache.keys.forEach { key -> playerCache.removeResource(key) } } clearCacheDialog = false }, onCancel = { clearCacheDialog = false }, content = { Text(text = stringResource(R.string.clear_song_cache_dialog)) }, ) } if (clearImageCacheDialog) { ActionPromptDialog( title = stringResource(R.string.clear_image_cache), onDismiss = { clearImageCacheDialog = false }, onConfirm = { coroutineScope.launch(Dispatchers.IO) { val urlsToPreserve = mutableSetOf() val downloadedSongs = try { database.downloadedSongsByNameAsc().first() } catch (e: Exception) { emptyList() } downloadedSongs.forEach { song -> song.song.thumbnailUrl?.let { urlsToPreserve.add(it.encodeUtf8().sha256().hex()) } song.album?.thumbnailUrl?.let { urlsToPreserve.add(it.encodeUtf8().sha256().hex()) } } val directory = imageDiskCache.directory.toFile() if (directory.exists() && directory.isDirectory) { directory.listFiles()?.forEach { file -> if (file.isFile && !file.name.startsWith("journal")) { val isPreserved = urlsToPreserve.any { hash -> file.name.startsWith(hash) } if (!isPreserved) { file.delete() } } } } imageDiskCache.clear() } clearImageCacheDialog = false }, onCancel = { clearImageCacheDialog = false }, content = { Text(text = stringResource(R.string.clear_image_cache_dialog)) }, ) } // Confirmation Dialog if (showCacheWarningDialog) { AlertDialog( onDismissRequest = { showCacheWarningDialog = false }, title = { Text(stringResource(R.string.cache_size_warning_title)) }, text = { Text( stringResource( R.string.cache_size_warning_message, formatFileSize(cacheUsage), cacheType, ), ) }, confirmButton = { TextButton( onClick = { onConfirmAction() showCacheWarningDialog = false }, ) { Text( stringResource(R.string.cache_size_warning_confirm), color = MaterialTheme.colorScheme.error, ) } }, dismissButton = { TextButton(onClick = { showCacheWarningDialog = false }) { Text(stringResource(id = android.R.string.cancel)) } }, ) } Column( Modifier .windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, ), ).verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Top, ), ), ) Material3SettingsGroup( title = stringResource(R.string.storage), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.storage), title = { Text(stringResource(R.string.downloaded_songs)) }, description = { Text(text = formatFileSize(downloadCacheSize)) }, ), Material3SettingsItem( icon = painterResource(R.drawable.clear_all), title = { Text(stringResource(R.string.clear_all_downloads)) }, onClick = { clearDownloads = true }, ), ), ) Material3SettingsGroup( title = stringResource(R.string.song_cache), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.cached), title = { Text(stringResource(R.string.enable_song_cache)) }, description = { Text(stringResource(R.string.enable_song_cache_desc)) }, trailingContent = { Switch( checked = enableSongCache, onCheckedChange = onEnableSongCacheChange, thumbContent = { Icon( painter = painterResource( id = if (enableSongCache) R.drawable.check else R.drawable.close ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize) ) } ) }, onClick = { onEnableSongCacheChange(!enableSongCache) } ), Material3SettingsItem( icon = painterResource(R.drawable.cached), title = { Text(stringResource(R.string.max_song_cache_size)) }, enabled = enableSongCache, description = { val songCacheValues = remember { listOf(0, 128, 256, 512, 1024, 2048, 4096, 8192, -1) } Column { Text( text = when (maxSongCacheSize) { 0 -> stringResource(R.string.disable) -1 -> stringResource(R.string.unlimited) else -> formatFileSize(maxSongCacheSize * 1024 * 1024L) } ) Slider( value = songCacheValues.indexOf(maxSongCacheSize).toFloat(), enabled = enableSongCache, onValueChange = { val newValue = songCacheValues[it.roundToInt()] val newLimitInBytes = if (newValue == -1) { Long.MAX_VALUE } else { newValue * 1024 * 1024L } if (newLimitInBytes < playerCacheSize) { cacheUsage = playerCacheSize cacheType = songCacheString onConfirmAction = { onMaxSongCacheSizeChange(newValue) } showCacheWarningDialog = true } else { onMaxSongCacheSizeChange(newValue) } }, steps = songCacheValues.size - 2, valueRange = 0f..(songCacheValues.size - 1).toFloat(), ) LinearProgressIndicator( progress = { playerCacheProgress }, modifier = Modifier.fillMaxWidth(), strokeCap = StrokeCap.Round, ) Spacer(modifier = Modifier.padding(2.dp)) Text( text = if (maxSongCacheSize == -1) { formatFileSize(playerCacheSize) } else { "${formatFileSize(playerCacheSize)} / ${ formatFileSize( maxSongCacheSize * 1024 * 1024L, ) }" }, style = MaterialTheme.typography.bodyMedium, ) } }, ), Material3SettingsItem( icon = painterResource(R.drawable.clear_all), title = { Text(stringResource(R.string.clear_song_cache)) }, onClick = { clearCacheDialog = true }, ), ), ) Material3SettingsGroup( title = stringResource(R.string.image_cache), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.manage_search), title = { Text(stringResource(R.string.max_image_cache_size)) }, description = { val imageCacheValues = remember { listOf(0, 128, 256, 512, 1024, 2048, 4096, 8192) } Column { Text( text = when (maxImageCacheSize) { 0 -> stringResource(R.string.disable) else -> formatFileSize(maxImageCacheSize * 1024 * 1024L) }, ) Slider( value = imageCacheValues.indexOf(maxImageCacheSize).toFloat(), onValueChange = { val newValue = imageCacheValues[it.roundToInt()] val newLimitInBytes = newValue * 1024 * 1024L if (newLimitInBytes < imageCacheSize) { cacheUsage = imageCacheSize cacheType = imageCacheString onConfirmAction = { onMaxImageCacheSizeChange(newValue) } showCacheWarningDialog = true } else { onMaxImageCacheSizeChange(newValue) } }, steps = imageCacheValues.size - 2, valueRange = 0f..(imageCacheValues.size - 1).toFloat(), ) LinearProgressIndicator( progress = { imageCacheProgress }, modifier = Modifier.fillMaxWidth(), strokeCap = StrokeCap.Round, ) Spacer(modifier = Modifier.padding(2.dp)) Text( text = "${formatFileSize(imageCacheSize)} / ${ formatFileSize( maxImageCacheSize * 1024 * 1024L, ) }", style = MaterialTheme.typography.bodyMedium, ) } }, ), Material3SettingsItem( icon = painterResource(R.drawable.clear_all), title = { Text(stringResource(R.string.clear_image_cache)) }, onClick = { clearImageCacheDialog = true }, ), ), ) } TopAppBar( title = { Text(stringResource(R.string.storage)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/ThemeScreen.kt ================================================ package com.metrolist.music.ui.screens.settings import android.content.res.Configuration import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas 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.isSystemInDarkTheme 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.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.material3.Card import androidx.compose.material3.CardDefaults 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.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.ripple 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.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.materialkolor.PaletteStyle import com.materialkolor.rememberDynamicColorScheme import com.metrolist.music.R import com.metrolist.music.constants.DarkModeKey import com.metrolist.music.constants.DynamicThemeKey import com.metrolist.music.constants.PureBlackKey import com.metrolist.music.constants.PureBlackMiniPlayerKey import com.metrolist.music.constants.SelectedThemeColorKey import com.metrolist.music.ui.theme.DefaultThemeColor import com.metrolist.music.ui.theme.MetrolistTheme import com.metrolist.music.utils.rememberEnumPreference import com.metrolist.music.utils.rememberPreference data class ThemePalette( val nameRes: Int, val seedColor: Color ) val PaletteColors = listOf( ThemePalette(R.string.palette_dynamic, Color.Transparent), // Sentinel for System/Dynamic colors ThemePalette(R.string.palette_crimson, Color(0xFFEC5464)), // Slightly shifted from DefaultThemeColor (0xFFED5564) to avoid conflict ThemePalette(R.string.palette_rose, Color(0xFFD81B60)), ThemePalette(R.string.palette_purple, Color(0xFF8E24AA)), ThemePalette(R.string.palette_deep_purple, Color(0xFF5E35B1)), ThemePalette(R.string.palette_indigo, Color(0xFF3949AB)), ThemePalette(R.string.palette_blue, Color(0xFF1E88E5)), ThemePalette(R.string.palette_sky_blue, Color(0xFF039BE5)), ThemePalette(R.string.palette_cyan, Color(0xFF00ACC1)), ThemePalette(R.string.palette_teal, Color(0xFF00897B)), ThemePalette(R.string.palette_green, Color(0xFF43A047)), ThemePalette(R.string.palette_light_green, Color(0xFF7CB342)), ThemePalette(R.string.palette_lime, Color(0xFFC0CA33)), ThemePalette(R.string.palette_yellow, Color(0xFFFDD835)), ThemePalette(R.string.palette_amber, Color(0xFFFFB300)), ThemePalette(R.string.palette_orange, Color(0xFFFB8C00)), ThemePalette(R.string.palette_deep_orange, Color(0xFFF4511E)), ThemePalette(R.string.palette_brown, Color(0xFF6D4C41)), ThemePalette(R.string.palette_grey, Color(0xFF757575)), ThemePalette(R.string.palette_blue_grey, Color(0xFF546E7A)), ) @OptIn(ExperimentalMaterial3Api::class) @Composable fun ThemeScreen( navController: NavController, ) { val (darkMode, onDarkModeChange) = rememberEnumPreference(DarkModeKey, DarkMode.AUTO) val (pureBlack, onPureBlackChangeRaw) = rememberPreference(PureBlackKey, defaultValue = false) val (_, onPureBlackMiniPlayerChange) = rememberPreference( PureBlackMiniPlayerKey, defaultValue = false ) val onPureBlackChange: (Boolean) -> Unit = { enabled -> onPureBlackChangeRaw(enabled) onPureBlackMiniPlayerChange(enabled) } val (selectedThemeColorInt, onSelectedThemeColorChange) = rememberPreference( SelectedThemeColorKey, DefaultThemeColor.toArgb() ) val (_, onDynamicThemeChange) = rememberPreference(DynamicThemeKey, defaultValue = true) val selectedThemeColor = Color(selectedThemeColorInt) val configuration = LocalConfiguration.current val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE // Helper function to handle color selection with dynamic theme toggle val handleColorSelection: (Color) -> Unit = { color -> onSelectedThemeColorChange(color.toArgb()) // Enable dynamic theme only when selecting the default/dynamic color // Disable it when selecting any other color val isDynamicColor = color == DefaultThemeColor onDynamicThemeChange(isDynamicColor) } if (isLandscape) { LandscapeThemeLayout( innerPadding = PaddingValues(0.dp), darkMode = darkMode, onDarkModeChange = onDarkModeChange, pureBlack = pureBlack, onPureBlackChange = onPureBlackChange, selectedThemeColor = selectedThemeColor, onSelectedThemeColorChange = handleColorSelection ) } else { PortraitThemeLayout( innerPadding = PaddingValues(0.dp), darkMode = darkMode, onDarkModeChange = onDarkModeChange, pureBlack = pureBlack, onPureBlackChange = onPureBlackChange, selectedThemeColor = selectedThemeColor, onSelectedThemeColorChange = handleColorSelection ) } TopAppBar( title = { Text(stringResource(R.string.theme_colors)) }, navigationIcon = { IconButton(onClick = { navController.navigateUp() }) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = stringResource(R.string.cd_back) ) } } ) } @Composable fun PortraitThemeLayout( innerPadding: PaddingValues, darkMode: DarkMode, onDarkModeChange: (DarkMode) -> Unit, pureBlack: Boolean, onPureBlackChange: (Boolean) -> Unit, selectedThemeColor: Color, onSelectedThemeColorChange: (Color) -> Unit ) { Column( modifier = Modifier .fillMaxSize() .padding(innerPadding), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.weight(1f)) Box( modifier = Modifier .width(120.dp) .height(240.dp), contentAlignment = Alignment.Center ) { ThemeMockupPortrait( darkMode = darkMode, pureBlack = pureBlack, themeColor = selectedThemeColor ) } Spacer(modifier = Modifier.weight(1f)) ThemeControls( darkMode = darkMode, onDarkModeChange = onDarkModeChange, pureBlack = pureBlack, onPureBlackChange = onPureBlackChange, selectedThemeColor = selectedThemeColor, onSelectedThemeColorChange = onSelectedThemeColorChange ) Spacer(modifier = Modifier.height(120.dp)) } } @Composable fun LandscapeThemeLayout( innerPadding: PaddingValues, darkMode: DarkMode, onDarkModeChange: (DarkMode) -> Unit, pureBlack: Boolean, onPureBlackChange: (Boolean) -> Unit, selectedThemeColor: Color, onSelectedThemeColorChange: (Color) -> Unit ) { Row( modifier = Modifier .fillMaxSize() .padding(innerPadding) ) { Column( modifier = Modifier .weight(0.4f) .fillMaxHeight() .padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Box( modifier = Modifier .fillMaxWidth(0.8f) .heightIn(max = 300.dp), contentAlignment = Alignment.Center ) { ThemeMockup( darkMode = darkMode, pureBlack = pureBlack, themeColor = selectedThemeColor ) } } Column( modifier = Modifier .weight(0.6f) .fillMaxHeight() .verticalScroll(rememberScrollState()) .padding(end = 16.dp, top = 16.dp, bottom = 16.dp) ) { ThemeControls( darkMode = darkMode, onDarkModeChange = onDarkModeChange, pureBlack = pureBlack, onPureBlackChange = onPureBlackChange, selectedThemeColor = selectedThemeColor, onSelectedThemeColorChange = onSelectedThemeColorChange ) Spacer(modifier = Modifier.height(80.dp)) } } } @Composable fun ThemeControls( darkMode: DarkMode, onDarkModeChange: (DarkMode) -> Unit, pureBlack: Boolean, onPureBlackChange: (Boolean) -> Unit, selectedThemeColor: Color, onSelectedThemeColorChange: (Color) -> Unit ) { Card( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column( modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(24.dp) ) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = stringResource(R.string.theme_mode), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically ) { // System mode (AUTO) ModeCircle( darkMode = darkMode, pureBlack = pureBlack, targetMode = DarkMode.AUTO, targetPureBlack = pureBlack, onClick = { onDarkModeChange(DarkMode.AUTO) }, showIcon = true ) // Vertical divider to separate System from manual modes Box( modifier = Modifier .width(1.dp) .height(32.dp) .background(MaterialTheme.colorScheme.outlineVariant) ) // Manual modes (Light, Dark, Pure Black) ModeCircle( darkMode = darkMode, pureBlack = pureBlack, targetMode = DarkMode.OFF, targetPureBlack = false, onClick = { onDarkModeChange(DarkMode.OFF) onPureBlackChange(false) }, showIcon = false ) ModeCircle( darkMode = darkMode, pureBlack = pureBlack, targetMode = DarkMode.ON, targetPureBlack = false, onClick = { onDarkModeChange(DarkMode.ON) onPureBlackChange(false) }, showIcon = false ) ModeCircle( darkMode = darkMode, pureBlack = pureBlack, targetMode = DarkMode.ON, targetPureBlack = true, onClick = { onDarkModeChange(DarkMode.ON) onPureBlackChange(true) }, showIcon = false ) } } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( text = stringResource(R.string.color_palette), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface ) LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), contentPadding = PaddingValues(horizontal = 4.dp) ) { items(PaletteColors) { palette -> val isDynamicPalette = palette.seedColor == Color.Transparent val isSelected = if (isDynamicPalette) { selectedThemeColor == DefaultThemeColor } else { selectedThemeColor == palette.seedColor } PaletteItem( palette = palette, isSelected = isSelected, onClick = { val colorToSave = if (isDynamicPalette) DefaultThemeColor else palette.seedColor onSelectedThemeColorChange(colorToSave) } ) } } } } } } @Composable fun ModeCircle( darkMode: DarkMode, pureBlack: Boolean, targetMode: DarkMode, targetPureBlack: Boolean, showIcon: Boolean, onClick: () -> Unit ) { val context = LocalContext.current val isSystemDark = isSystemInDarkTheme() val isSelected = darkMode == targetMode && pureBlack == targetPureBlack val effectiveDark = when (targetMode) { DarkMode.AUTO -> isSystemDark DarkMode.ON -> true DarkMode.OFF -> false } // Use actual system colors for AUTO mode on Android 12+ val modeColorScheme = if (targetMode == DarkMode.AUTO && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (effectiveDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } else { rememberDynamicColorScheme( seedColor = DefaultThemeColor, isDark = effectiveDark, style = PaletteStyle.TonalSpot ) } val fillColor = when { targetPureBlack -> Color.Black effectiveDark -> modeColorScheme.surface else -> modeColorScheme.surface } // Animated border width val borderWidth by animateDpAsState( targetValue = if (isSelected) 3.dp else 0.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium ), label = "borderWidth" ) // Animated scale for the entire circle val scale by animateFloatAsState( targetValue = if (isSelected) 1.05f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium ), label = "scale" ) val interactionSource = remember { MutableInteractionSource() } val contentDesc = when { targetPureBlack -> stringResource(R.string.cd_pure_black_mode) targetMode == DarkMode.OFF -> stringResource(R.string.cd_light_mode) targetMode == DarkMode.ON -> stringResource(R.string.cd_dark_mode) else -> stringResource(R.string.cd_system_mode) } Box( modifier = Modifier .size(48.dp) .graphicsLayer { scaleX = scale scaleY = scale } .clip(CircleShape) .background(fillColor) .then( if (borderWidth > 0.dp) { Modifier.border( width = borderWidth, color = MaterialTheme.colorScheme.inversePrimary, shape = CircleShape ) } else { Modifier } ) .clickable( interactionSource = interactionSource, indication = ripple(), onClick = onClick ) .semantics { contentDescription = contentDesc }, contentAlignment = Alignment.Center ) { when { showIcon -> { Icon( painter = painterResource(R.drawable.sync), contentDescription = null, tint = modeColorScheme.onSurface, modifier = Modifier.size(20.dp) ) } isSelected -> { AnimatedVisibility( visible = isSelected, enter = fadeIn(animationSpec = tween(300)) + scaleIn( initialScale = 0.3f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium ) ), exit = fadeOut(animationSpec = tween(150)) + scaleOut( targetScale = 0.3f, animationSpec = tween(150) ) ) { Icon( painter = painterResource(R.drawable.check), contentDescription = null, tint = MaterialTheme.colorScheme.inversePrimary, modifier = Modifier.size(20.dp) ) } } } } } @Composable fun PaletteItem( palette: ThemePalette, isSelected: Boolean, onClick: () -> Unit ) { val isSystemDark = isSystemInDarkTheme() val colorScheme = rememberDynamicColorScheme( seedColor = palette.seedColor, isDark = isSystemDark, style = PaletteStyle.TonalSpot ) val cornerRadius by animateDpAsState( targetValue = if (isSelected) 48.dp * 0.25f else 24.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMedium ), label = "cornerRadius" ) val borderWidth by animateDpAsState( targetValue = if (isSelected) 3.dp else 0.dp, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium ), label = "borderWidth" ) val scale by animateFloatAsState( targetValue = if (isSelected) 1.08f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium ), label = "scale" ) val shape = RoundedCornerShape(cornerRadius) val interactionSource = remember { MutableInteractionSource() } val paletteName = stringResource(palette.nameRes) val contentDesc = stringResource(R.string.cd_palette_item, paletteName) Box( modifier = Modifier .size(48.dp) .graphicsLayer { scaleX = scale scaleY = scale } .clip(shape) .then( if (borderWidth > 0.dp) { Modifier.border( width = borderWidth, color = MaterialTheme.colorScheme.inversePrimary, shape = shape ) } else { Modifier } ) .clickable( interactionSource = interactionSource, indication = ripple(), onClick = onClick ) .semantics { contentDescription = contentDesc } ) { if (palette.seedColor == Color.Transparent) { // Draw Dynamic/System icon using Material Design icon Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(R.drawable.palette), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(24.dp) ) } } else { Canvas(modifier = Modifier.fillMaxSize()) { val width = size.width val height = size.height drawRect( color = colorScheme.onPrimary, topLeft = Offset(0f, 0f), size = Size(width, height / 2) ) drawRect( color = colorScheme.secondary, topLeft = Offset(0f, height / 2), size = Size(width / 2, height / 2) ) drawRect( color = colorScheme.tertiary, topLeft = Offset(width / 2, height / 2), size = Size(width / 2, height / 2) ) } } } } @Composable fun ThemeMockup( darkMode: DarkMode, pureBlack: Boolean, themeColor: Color ) { val isSystemDark = isSystemInDarkTheme() val useDark = when (darkMode) { DarkMode.AUTO -> isSystemDark DarkMode.ON -> true DarkMode.OFF -> false } MetrolistTheme( darkTheme = useDark, pureBlack = pureBlack, themeColor = themeColor ) { Card( modifier = Modifier .fillMaxSize() .aspectRatio(9f / 18f), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Column( modifier = Modifier.fillMaxSize() ) { Box( modifier = Modifier .fillMaxWidth() .height(40.dp) .background(MaterialTheme.colorScheme.surfaceContainer) .padding(10.dp), contentAlignment = Alignment.CenterStart ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(18.dp) .background(MaterialTheme.colorScheme.primary, CircleShape) ) Box( modifier = Modifier .size(18.dp) .background(MaterialTheme.colorScheme.secondary, CircleShape) ) } } Column( modifier = Modifier .weight(1f) .padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { Box( modifier = Modifier .fillMaxWidth() .height(32.dp) .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(6.dp)) ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp) ) { Box( modifier = Modifier .weight(1f) .height(40.dp) .background(MaterialTheme.colorScheme.secondary, RoundedCornerShape(6.dp)) ) Box( modifier = Modifier .weight(1f) .height(40.dp) .background(MaterialTheme.colorScheme.tertiary, RoundedCornerShape(6.dp)) ) } } Box( modifier = Modifier .fillMaxWidth() .padding(10.dp), contentAlignment = Alignment.BottomEnd ) { Box( modifier = Modifier .size(30.dp) .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) ) } } } } } @Composable fun ThemeMockupPortrait( darkMode: DarkMode, pureBlack: Boolean, themeColor: Color ) { val isSystemDark = isSystemInDarkTheme() val useDark = when (darkMode) { DarkMode.AUTO -> isSystemDark DarkMode.ON -> true DarkMode.OFF -> false } MetrolistTheme( darkTheme = useDark, pureBlack = pureBlack, themeColor = themeColor ) { Card( modifier = Modifier .fillMaxSize(), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) ) { Column( modifier = Modifier.fillMaxSize() ) { // Header (20% of height) Box( modifier = Modifier .fillMaxWidth() .weight(0.2f) .background(MaterialTheme.colorScheme.surfaceContainer) .padding(6.dp), contentAlignment = Alignment.CenterStart ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier .size(12.dp) .background(MaterialTheme.colorScheme.primary, CircleShape) ) Box( modifier = Modifier .size(12.dp) .background(MaterialTheme.colorScheme.secondary, CircleShape) ) } } // Main Content (60% of height) Column( modifier = Modifier .weight(0.6f) .padding(6.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { Box( modifier = Modifier .fillMaxWidth() .weight(1f) .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(4.dp)) ) Row( modifier = Modifier .fillMaxWidth() .weight(1.2f), horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Box( modifier = Modifier .weight(1f) .fillMaxHeight() .background(MaterialTheme.colorScheme.secondary, RoundedCornerShape(4.dp)) ) Box( modifier = Modifier .weight(1f) .fillMaxHeight() .background(MaterialTheme.colorScheme.tertiary, RoundedCornerShape(4.dp)) ) } } // FAB Area (20% of height) Box( modifier = Modifier .fillMaxWidth() .weight(0.2f) .padding(6.dp), contentAlignment = Alignment.BottomEnd ) { Box( modifier = Modifier .size(18.dp) .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/UpdaterSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings import androidx.compose.foundation.layout.Column 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.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch 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.rememberCoroutineScope 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.unit.dp import androidx.navigation.NavController import com.metrolist.music.BuildConfig import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.CheckForUpdatesKey import com.metrolist.music.constants.UpdateNotificationsEnabledKey import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.Updater import com.metrolist.music.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable fun UpdaterScreen( navController: NavController ) { val (checkForUpdates, onCheckForUpdatesChange) = rememberPreference(CheckForUpdatesKey, true) val (updateNotifications, onUpdateNotificationsChange) = rememberPreference(UpdateNotificationsEnabledKey, true) val context = LocalContext.current var isChecking by remember { mutableStateOf(false) } var updateAvailable by remember { mutableStateOf(false) } var latestVersion by remember { mutableStateOf(null) } var showChangelog by remember { mutableStateOf(false) } var changelogContent by remember { mutableStateOf(null) } var checkError by remember { mutableStateOf(null) } val failedToCheckUpdatesTemplate = stringResource(R.string.failed_to_check_updates) val coroutineScope = rememberCoroutineScope() fun performManualCheck() { coroutineScope.launch { isChecking = true checkError = null withContext(Dispatchers.IO) { Updater .checkForUpdate(forceRefresh = true) .onSuccess { (releaseInfo, hasUpdate) -> if (releaseInfo != null) { latestVersion = releaseInfo.versionName updateAvailable = hasUpdate changelogContent = releaseInfo.description } }.onFailure { checkError = String.format(failedToCheckUpdatesTemplate, it.message ?: "Unknown error") } } isChecking = false } } Column( modifier = Modifier .fillMaxWidth() .windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, ), ).verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Top, ), ), ) Spacer(Modifier.height(4.dp)) Material3SettingsGroup( title = stringResource(R.string.current_version), items = listOf( Material3SettingsItem( title = { Text(stringResource(R.string.version_format, BuildConfig.VERSION_NAME)) }, description = { val arch = BuildConfig.ARCHITECTURE val variant = if (BuildConfig.CAST_AVAILABLE) "GMS" else "FOSS" Text("$arch - $variant") }, ), ), ) Spacer(Modifier.height(16.dp)) Material3SettingsGroup( title = stringResource(R.string.update_settings), items = buildList { add( Material3SettingsItem( title = { Text(stringResource(R.string.check_for_updates)) }, icon = painterResource(R.drawable.update), trailingContent = { Switch( checked = checkForUpdates, onCheckedChange = onCheckForUpdatesChange, ) }, onClick = { onCheckForUpdatesChange(!checkForUpdates) }, ), ) if (checkForUpdates) { add( Material3SettingsItem( title = { Text(stringResource(R.string.update_notifications)) }, icon = painterResource(R.drawable.notification), trailingContent = { Switch( checked = updateNotifications, onCheckedChange = onUpdateNotificationsChange, ) }, onClick = { onUpdateNotificationsChange(!updateNotifications) }, ), ) } }, ) Spacer(Modifier.height(16.dp)) Material3SettingsGroup( title = stringResource(R.string.check_for_updates_title), items = listOf( Material3SettingsItem( icon = painterResource(R.drawable.refresh), title = { if (isChecking) { Text(stringResource(R.string.checking_for_updates)) } else if (latestVersion != null) { Text(stringResource(R.string.latest_version_format, latestVersion!!)) } else { Text(stringResource(R.string.check_for_updates_button)) } }, trailingContent = { if (isChecking) { CircularProgressIndicator( modifier = Modifier.padding(end = 16.dp), strokeWidth = 2.dp, ) } else if (updateAvailable) { Icon( painter = painterResource(R.drawable.download), contentDescription = stringResource(R.string.update_available_title), tint = MaterialTheme.colorScheme.primary, ) } }, onClick = { if (!isChecking) performManualCheck() }, ), ), ) checkError?.let { Spacer(Modifier.height(12.dp)) Text( text = it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 16.dp), ) } if (updateAvailable && latestVersion != null) { Spacer(Modifier.height(16.dp)) Button( onClick = { showChangelog = !showChangelog }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) { Text(if (showChangelog) stringResource(R.string.hide_changelog) else stringResource(R.string.view_changelog)) } if (showChangelog && changelogContent != null) { Spacer(Modifier.height(12.dp)) Text( text = changelogContent!!, style = MaterialTheme.typography.bodySmall, modifier = Modifier .fillMaxWidth() .padding(16.dp), ) } } Spacer(Modifier.height(32.dp)) } TopAppBar( title = { Text(stringResource(R.string.updater)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painter = painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/DiscordSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings.integrations import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize 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.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.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult 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.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.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.TextFieldValue 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.net.toUri import androidx.media3.common.Player.STATE_READY import androidx.navigation.NavController import coil3.compose.AsyncImage import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.constants.DiscordActivityNameKey import com.metrolist.music.constants.DiscordActivityTypeKey import com.metrolist.music.constants.DiscordAdvancedModeKey import com.metrolist.music.constants.DiscordAvatarKey import com.metrolist.music.constants.DiscordButton1TextKey import com.metrolist.music.constants.DiscordButton1VisibleKey import com.metrolist.music.constants.DiscordButton2TextKey import com.metrolist.music.constants.DiscordButton2VisibleKey import com.metrolist.music.constants.DiscordInfoDismissedKey import com.metrolist.music.constants.DiscordNameKey import com.metrolist.music.constants.DiscordStatusKey import com.metrolist.music.constants.DiscordTokenKey import com.metrolist.music.constants.DiscordUseDetailsKey import com.metrolist.music.constants.DiscordUsernameKey import com.metrolist.music.constants.EnableDiscordRPCKey import com.metrolist.music.db.entities.Song import com.metrolist.music.ui.component.EnumDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.InfoLabel import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.component.TextFieldDialog import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.DiscordRPC import com.metrolist.music.utils.SuperProperties import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberPreference import com.my.kizzy.rpc.KizzyRPC import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch private enum class DiscordStatus { ONLINE, IDLE, DND } private enum class DiscordActivityType { LISTENING, PLAYING, WATCHING, COMPETING } @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun DiscordSettings( navController: NavController, snackbarHostState: SnackbarHostState, ) { val playerConnection = LocalPlayerConnection.current ?: return val song by playerConnection.currentSong.collectAsState(null) val playbackState by playerConnection.playbackState.collectAsState() var position by rememberSaveable(playbackState) { mutableLongStateOf(playerConnection.player.currentPosition) } val coroutineScope = rememberCoroutineScope() val loginSuccessfulStr = stringResource(R.string.login_successful) // Preferences var discordToken by rememberPreference(DiscordTokenKey, "") var discordUsername by rememberPreference(DiscordUsernameKey, "") var discordName by rememberPreference(DiscordNameKey, "") var discordAvatar by rememberPreference(DiscordAvatarKey, "") var infoDismissed by rememberPreference(DiscordInfoDismissedKey, false) val (discordRPC, onDiscordRPCChange) = rememberPreference(EnableDiscordRPCKey, true) val (useDetails, onUseDetailsChange) = rememberPreference(DiscordUseDetailsKey, false) val (advancedMode, onAdvancedModeChange) = rememberPreference(DiscordAdvancedModeKey, false) var discordStatus by rememberPreference(DiscordStatusKey, "online") var button1Text by rememberPreference(DiscordButton1TextKey, "") var button1Visible by rememberPreference(DiscordButton1VisibleKey, true) var button2Text by rememberPreference(DiscordButton2TextKey, "") var button2Visible by rememberPreference(DiscordButton2VisibleKey, true) var activityType by rememberPreference(DiscordActivityTypeKey, "listening") var activityName by rememberPreference(DiscordActivityNameKey, "") val isLoggedIn = remember(discordToken) { discordToken.isNotEmpty() } var showTokenDialog by rememberSaveable { mutableStateOf(false) } var showStatusDialog by rememberSaveable { mutableStateOf(false) } var showActivityTypeDialog by rememberSaveable { mutableStateOf(false) } var showButton1TextDialog by rememberSaveable { mutableStateOf(false) } var showButton2TextDialog by rememberSaveable { mutableStateOf(false) } var showActivityNameDialog by rememberSaveable { mutableStateOf(false) } // Map string prefs to enums for dialogs val currentStatus = when (discordStatus) { "idle" -> DiscordStatus.IDLE "dnd" -> DiscordStatus.DND else -> DiscordStatus.ONLINE } val currentActivityType = when (activityType) { "playing" -> DiscordActivityType.PLAYING "watching" -> DiscordActivityType.WATCHING "competing" -> DiscordActivityType.COMPETING else -> DiscordActivityType.LISTENING } // Fetch user info when token changes LaunchedEffect(discordToken) { val token = discordToken if (token.isEmpty()) { discordUsername = "" discordName = "" discordAvatar = "" return@LaunchedEffect } launch(Dispatchers.IO) { KizzyRPC .getUserInfo( token, SuperProperties.userAgent, SuperProperties.superPropertiesBase64, ).onSuccess { discordUsername = it.username discordName = it.name discordAvatar = it.avatar ?: "" }.onFailure { discordUsername = "" discordName = "" discordAvatar = "" } } } // Update playback position LaunchedEffect(playbackState) { if (playbackState == STATE_READY) { while (isActive) { delay(100) position = playerConnection.player.currentPosition } } } // Dialogs if (showTokenDialog) { var isVerifying by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } TextFieldDialog( onDismiss = { showTokenDialog = false }, icon = { Icon(painterResource(R.drawable.token), null) }, autoDismiss = false, onDone = { token -> isVerifying = true error = null coroutineScope.launch(Dispatchers.IO) { KizzyRPC .getUserInfo( token, SuperProperties.userAgent, SuperProperties.superPropertiesBase64, ).onSuccess { discordToken = token showTokenDialog = false snackbarHostState.showSnackbar(loginSuccessfulStr) }.onFailure { error = "Invalid token" isVerifying = false } } }, singleLine = true, isInputValid = { it.isNotEmpty() }, extraContent = { if (isVerifying) { LinearProgressIndicator( modifier = Modifier .fillMaxWidth() .padding(bottom = 8.dp), ) } if (error != null) { Text( text = error!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 8.dp), ) } InfoLabel(text = stringResource(R.string.token_adv_login_description)) }, ) } if (showStatusDialog) { EnumDialog( onDismiss = { showStatusDialog = false }, onSelect = { selected -> discordStatus = when (selected) { DiscordStatus.IDLE -> "idle" DiscordStatus.DND -> "dnd" DiscordStatus.ONLINE -> "online" } showStatusDialog = false }, title = stringResource(R.string.discord_status), current = currentStatus, values = DiscordStatus.entries.toList(), valueText = { when (it) { DiscordStatus.ONLINE -> stringResource(R.string.discord_status_online) DiscordStatus.IDLE -> stringResource(R.string.discord_status_idle) DiscordStatus.DND -> stringResource(R.string.discord_status_dnd) } }, ) } if (showActivityTypeDialog) { EnumDialog( onDismiss = { showActivityTypeDialog = false }, onSelect = { selected -> activityType = when (selected) { DiscordActivityType.PLAYING -> "playing" DiscordActivityType.WATCHING -> "watching" DiscordActivityType.COMPETING -> "competing" DiscordActivityType.LISTENING -> "listening" } showActivityTypeDialog = false }, title = stringResource(R.string.discord_activity_type), current = currentActivityType, values = DiscordActivityType.entries.toList(), valueText = { when (it) { DiscordActivityType.LISTENING -> stringResource(R.string.discord_activity_listening) DiscordActivityType.PLAYING -> stringResource(R.string.discord_activity_playing) DiscordActivityType.WATCHING -> stringResource(R.string.discord_activity_watching) DiscordActivityType.COMPETING -> stringResource(R.string.discord_activity_competing) } }, ) } if (showButton1TextDialog) { TextFieldDialog( onDismiss = { showButton1TextDialog = false }, onDone = { button1Text = it showButton1TextDialog = false }, singleLine = true, initialTextFieldValue = TextFieldValue(button1Text), extraContent = { InfoLabel(text = stringResource(R.string.discord_button_text_variables)) }, ) } if (showButton2TextDialog) { TextFieldDialog( onDismiss = { showButton2TextDialog = false }, onDone = { button2Text = it showButton2TextDialog = false }, singleLine = true, initialTextFieldValue = TextFieldValue(button2Text), extraContent = { InfoLabel(text = stringResource(R.string.discord_button_text_variables)) }, ) } if (showActivityNameDialog) { TextFieldDialog( onDismiss = { showActivityNameDialog = false }, onDone = { activityName = it showActivityNameDialog = false }, singleLine = true, initialTextFieldValue = TextFieldValue(activityName), extraContent = { InfoLabel(text = stringResource(R.string.discord_activity_name_description)) }, ) } Column( modifier = Modifier .windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom, ), ).verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top), ), ) // Warning Card AnimatedVisibility(visible = !infoDismissed) { Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, ), modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), ) { Row( modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.Top, ) { Icon( painter = painterResource(R.drawable.warning), contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(24.dp), ) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = stringResource(R.string.discord_information_warning), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) Spacer(Modifier.height(8.dp)) TextButton( onClick = { infoDismissed = true }, modifier = Modifier.align(Alignment.End), ) { Text(stringResource(R.string.dismiss)) } } } } } // Profile Card (fully rounded) Card( shape = RoundedCornerShape(28.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp), ) { Row( modifier = Modifier .padding( start = 20.dp, end = 20.dp, top = 20.dp, bottom = if (isLoggedIn) 20.dp else 8.dp, ).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { // Avatar with status dot Box(modifier = Modifier.size(56.dp)) { if (isLoggedIn && discordAvatar.isNotEmpty()) { AsyncImage( model = discordAvatar, contentDescription = null, modifier = Modifier .size(56.dp) .clip(CircleShape), ) } else { Icon( painter = painterResource(R.drawable.discord), contentDescription = null, modifier = Modifier .size(36.dp) .align(Alignment.Center) .alpha(0.4f), ) } if (isLoggedIn) { val statusColor = when (discordStatus) { "idle" -> MaterialTheme.colorScheme.tertiary "dnd" -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.primary } Surface( color = statusColor, shape = CircleShape, modifier = Modifier .size(16.dp) .align(Alignment.BottomEnd) .border( 2.dp, MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape, ), content = {}, ) } } 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.Bold, modifier = Modifier.alpha(if (isLoggedIn) 1f else 0.5f), ) if (discordUsername.isNotEmpty()) { Text( text = "@$discordUsername", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } if (!isLoggedIn) { Text( text = stringResource(R.string.discord_connect_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } // Only show logout inline when logged in if (isLoggedIn) { OutlinedButton(onClick = { discordName = "" discordToken = "" discordUsername = "" discordAvatar = "" }) { Text(stringResource(R.string.action_logout)) } } } // Login buttons below when not logged in if (!isLoggedIn) { Row( modifier = Modifier .fillMaxWidth() .padding(start = 20.dp, end = 20.dp, bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedButton( onClick = { navController.navigate("settings/discord/login") }, ) { Text(stringResource(R.string.action_login)) } OutlinedButton( onClick = { showTokenDialog = true }, ) { Icon( painterResource(R.drawable.token), contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.advanced_login)) } } } } // Options section (card-based) Material3SettingsGroup( title = stringResource(R.string.options), items = listOf( Material3SettingsItem( title = { Text(stringResource(R.string.enable_discord_rpc)) }, trailingContent = { Switch( checked = discordRPC, onCheckedChange = onDiscordRPCChange, enabled = isLoggedIn, ) }, enabled = isLoggedIn, onClick = { if (isLoggedIn) onDiscordRPCChange(!discordRPC) }, ), Material3SettingsItem( title = { Text(stringResource(R.string.discord_use_details)) }, description = { Text(stringResource(R.string.discord_use_details_description)) }, trailingContent = { Switch( checked = useDetails, onCheckedChange = onUseDetailsChange, enabled = isLoggedIn && discordRPC, ) }, enabled = isLoggedIn && discordRPC, onClick = { if (isLoggedIn && discordRPC) onUseDetailsChange(!useDetails) }, ), Material3SettingsItem( title = { Text(stringResource(R.string.discord_advanced_mode)) }, description = { Text(stringResource(R.string.discord_advanced_mode_description)) }, trailingContent = { Switch( checked = advancedMode, onCheckedChange = onAdvancedModeChange, enabled = isLoggedIn && discordRPC, ) }, enabled = isLoggedIn && discordRPC, onClick = { if (isLoggedIn && discordRPC) onAdvancedModeChange(!advancedMode) }, ), ), ) Spacer(Modifier.height(8.dp)) // Advanced customization section AnimatedVisibility(visible = isLoggedIn && discordRPC && advancedMode) { Column(modifier = Modifier.animateContentSize()) { // Presence settings Material3SettingsGroup( title = stringResource(R.string.discord_presence), items = listOf( Material3SettingsItem( title = { Text(stringResource(R.string.discord_status)) }, description = { Text( when (currentStatus) { DiscordStatus.ONLINE -> { stringResource(R.string.discord_status_online) } DiscordStatus.IDLE -> { stringResource(R.string.discord_status_idle) } DiscordStatus.DND -> { stringResource(R.string.discord_status_dnd) } }, ) }, onClick = { showStatusDialog = true }, ), Material3SettingsItem( title = { Text(stringResource(R.string.discord_activity_type)) }, description = { Text( when (currentActivityType) { DiscordActivityType.LISTENING -> { stringResource(R.string.discord_activity_listening) } DiscordActivityType.PLAYING -> { stringResource(R.string.discord_activity_playing) } DiscordActivityType.WATCHING -> { stringResource(R.string.discord_activity_watching) } DiscordActivityType.COMPETING -> { stringResource(R.string.discord_activity_competing) } }, ) }, onClick = { showActivityTypeDialog = true }, ), Material3SettingsItem( title = { Text(stringResource(R.string.discord_activity_name)) }, description = { Text( activityName.ifEmpty { stringResource(R.string.discord_activity_name_description) }, ) }, onClick = { showActivityNameDialog = true }, ), ), ) Spacer(Modifier.height(8.dp)) // Button customization Material3SettingsGroup( title = stringResource(R.string.discord_buttons), items = listOf( Material3SettingsItem( title = { Text(stringResource(R.string.discord_button_1)) }, description = { Text(button1Text.ifEmpty { "Listen on YouTube Music" }) }, trailingContent = { Switch( checked = button1Visible, onCheckedChange = { button1Visible = it }, ) }, onClick = { showButton1TextDialog = true }, ), Material3SettingsItem( title = { Text(stringResource(R.string.discord_button_2)) }, description = { Text(button2Text.ifEmpty { "Visit Metrolist" }) }, trailingContent = { Switch( checked = button2Visible, onCheckedChange = { button2Visible = it }, ) }, onClick = { showButton2TextDialog = true }, ), ), ) // Variable hint Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, ), modifier = Modifier .fillMaxWidth() .padding(top = 8.dp), ) { Row( modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( painter = painterResource(R.drawable.info), contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(20.dp), ) Spacer(Modifier.width(8.dp)) Text( text = stringResource(R.string.discord_button_text_variables), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSecondaryContainer, ) } } Spacer(Modifier.height(8.dp)) } } // Preview section Spacer(Modifier.height(8.dp)) Text( text = stringResource(R.string.discord_rpc_preview), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp, top = 8.dp), ) RichPresence( song = song, currentPlaybackTimeMillis = position, activityType = activityType, activityName = activityName, button1Text = button1Text, button1Visible = button1Visible, button2Text = button2Text, button2Visible = button2Visible, ) // Bottom padding for mini player Spacer(Modifier.height(24.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, ) } }, ) } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun RichPresence( song: Song?, currentPlaybackTimeMillis: Long = 0L, activityType: String = "listening", activityName: String = "", button1Text: String = "", button1Visible: Boolean = true, button2Text: String = "", button2Visible: Boolean = true, ) { val context = LocalContext.current val activityLabel = when (activityType) { "playing" -> stringResource(R.string.discord_playing_metrolist) "watching" -> stringResource(R.string.discord_watching_metrolist) "competing" -> stringResource(R.string.discord_competing_metrolist) else -> stringResource(R.string.listening_to_metrolist) } Surface( color = MaterialTheme.colorScheme.surfaceContainer, shape = MaterialTheme.shapes.medium, shadowElevation = 6.dp, modifier = Modifier.fillMaxWidth(), ) { Column( modifier = Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = if (activityName.isNotEmpty()) activityName else activityLabel, style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Start, fontWeight = FontWeight.ExtraBold, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(16.dp)) Row(verticalAlignment = Alignment.Top) { Box(Modifier.size(108.dp)) { AsyncImage( model = song?.song?.thumbnailUrl, contentDescription = null, modifier = Modifier .size(96.dp) .clip(RoundedCornerShape(3.dp)) .align(Alignment.TopStart) .run { if (song == null) { border( 2.dp, MaterialTheme.colorScheme.onSurface, RoundedCornerShape(3.dp), ) } else { this } }, ) song?.artists?.firstOrNull()?.thumbnailUrl?.let { Box( modifier = Modifier .border( 2.dp, MaterialTheme.colorScheme.surfaceContainer, CircleShape, ).padding(2.dp) .align(Alignment.BottomEnd), ) { AsyncImage( model = it, contentDescription = null, modifier = Modifier .size(32.dp) .clip(CircleShape), ) } } } Column( modifier = Modifier .weight(1f) .padding(horizontal = 6.dp), ) { Text( text = song?.song?.title ?: "Song Title", color = MaterialTheme.colorScheme.onSurface, fontSize = 20.sp, fontWeight = FontWeight.ExtraBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = song?.artists?.joinToString { it.name } ?: "Artist", color = MaterialTheme.colorScheme.secondary, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) song?.album?.title?.let { Text( text = it, color = MaterialTheme.colorScheme.secondary, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } if (song != null) { SongProgressBar( currentTimeMillis = currentPlaybackTimeMillis, durationMillis = song.song.duration.times(1000L), ) } } } Spacer(modifier = Modifier.height(16.dp)) if (button1Visible) { val resolvedButton1 = if (song != null) { DiscordRPC.resolveVariables( button1Text.ifEmpty { "Listen on YouTube Music" }, song, ) } else { button1Text.ifEmpty { "Listen on YouTube Music" } } OutlinedButton( enabled = song != null, onClick = { val intent = Intent( Intent.ACTION_VIEW, "https://music.youtube.com/watch?v=${song?.id}".toUri(), ) context.startActivity(intent) }, modifier = Modifier.fillMaxWidth(), ) { Text(resolvedButton1) } } if (button2Visible) { val resolvedButton2 = if (song != null) { DiscordRPC.resolveVariables( button2Text.ifEmpty { "Visit Metrolist" }, song, ) } else { button2Text.ifEmpty { "Visit Metrolist" } } OutlinedButton( onClick = { val intent = Intent( Intent.ACTION_VIEW, "https://github.com/MetrolistGroup/Metrolist".toUri(), ) context.startActivity(intent) }, modifier = Modifier.fillMaxWidth(), ) { Text(resolvedButton2) } } } } } @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SongProgressBar( currentTimeMillis: Long, durationMillis: Long, ) { val progress = if (durationMillis > 0) currentTimeMillis.toFloat() / durationMillis else 0f Column(modifier = Modifier.fillMaxWidth()) { Spacer(modifier = Modifier.height(16.dp)) LinearWavyProgressIndicator( progress = { progress }, amplitude = { 1f }, wavelength = 16.dp, modifier = Modifier .fillMaxWidth() .height(6.dp), ) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( text = makeTimeString(currentTimeMillis), modifier = Modifier.weight(1f), textAlign = TextAlign.Start, fontSize = 12.sp, ) Text( text = makeTimeString(durationMillis), modifier = Modifier.weight(1f), textAlign = TextAlign.End, fontSize = 12.sp, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/IntegrationScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings.integrations import androidx.compose.foundation.layout.Column 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.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.IntegrationCard import com.metrolist.music.ui.component.IntegrationCardItem import com.metrolist.music.ui.utils.backToMain @OptIn(ExperimentalMaterial3Api::class) @Composable fun IntegrationScreen( navController: NavController ) { Column( Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp), ) { IntegrationCard( title = stringResource(R.string.general), items = listOf( IntegrationCardItem( icon = painterResource(R.drawable.discord), title = { Text(stringResource(R.string.discord_integration)) }, onClick = { navController.navigate("settings/integrations/discord") } ), IntegrationCardItem( icon = painterResource(R.drawable.music_note), title = { Text(stringResource(R.string.lastfm_integration)) }, onClick = { navController.navigate("settings/integrations/lastfm") } ) ) ) } TopAppBar( title = { Text(stringResource(R.string.integrations)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/LastFMSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings.integrations 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.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable 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.alpha import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.navigation.NavController import com.metrolist.lastfm.LastFM import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.EnableLastFMScrobblingKey import com.metrolist.music.constants.LastFMSessionKey import com.metrolist.music.constants.LastFMUseNowPlaying import com.metrolist.music.constants.LastFMUseSendLikes import com.metrolist.music.constants.LastFMUsernameKey import com.metrolist.music.constants.ScrobbleDelayPercentKey import com.metrolist.music.constants.ScrobbleDelaySecondsKey import com.metrolist.music.constants.ScrobbleMinSongDurationKey import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.makeTimeString import com.metrolist.music.utils.rememberPreference import com.metrolist.music.utils.reportException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable fun LastFMSettings( navController: NavController ) { val coroutineScope = rememberCoroutineScope() var lastfmUsername by rememberPreference(LastFMUsernameKey, "") var lastfmSession by rememberPreference(LastFMSessionKey, "") val isLoggedIn = remember(lastfmSession) { lastfmSession != "" } val (useNowPlaying, onUseNowPlayingChange) = rememberPreference( key = LastFMUseNowPlaying, defaultValue = false ) val (useSendLikes, onUseSendLikes) = rememberPreference( key = LastFMUseSendLikes, defaultValue = false ) val (lastfmScrobbling, onlastfmScrobblingChange) = rememberPreference( key = EnableLastFMScrobblingKey, defaultValue = false ) val (scrobbleDelayPercent, onScrobbleDelayPercentChange) = rememberPreference( ScrobbleDelayPercentKey, defaultValue = LastFM.DEFAULT_SCROBBLE_DELAY_PERCENT ) val (minTrackDuration, onMinTrackDurationChange) = rememberPreference( ScrobbleMinSongDurationKey, defaultValue = LastFM.DEFAULT_SCROBBLE_MIN_SONG_DURATION ) val (scrobbleDelaySeconds, onScrobbleDelaySecondsChange) = rememberPreference( ScrobbleDelaySecondsKey, defaultValue = LastFM.DEFAULT_SCROBBLE_DELAY_SECONDS ) var showLoginDialog by rememberSaveable { mutableStateOf(false) } var isLoggingIn by rememberSaveable { mutableStateOf(false) } var loginError by rememberSaveable { mutableStateOf(null) } if (showLoginDialog) { var tempUsername by rememberSaveable { mutableStateOf("") } var tempPassword by rememberSaveable { mutableStateOf("") } AlertDialog( properties = DialogProperties(usePlatformDefaultWidth = false), onDismissRequest = { if (!isLoggingIn) { showLoginDialog = false loginError = null } }, title = { Text(stringResource(R.string.login)) }, text = { Column( modifier = Modifier .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { OutlinedTextField( value = tempUsername, onValueChange = { tempUsername = it loginError = null }, label = { Text(stringResource(R.string.username)) }, singleLine = true, enabled = !isLoggingIn, ) OutlinedTextField( value = tempPassword, onValueChange = { tempPassword = it loginError = null }, label = { Text(stringResource(R.string.password)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), enabled = !isLoggingIn, ) // Show error message if login failed loginError?.let { error -> Text( text = error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp) ) } // Show loading indicator if (isLoggingIn) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator( modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.size(8.dp)) Text( text = stringResource(R.string.logging_in), style = MaterialTheme.typography.bodySmall ) } } } }, confirmButton = { TextButton( onClick = { if (tempUsername.isBlank() || tempPassword.isBlank()) { loginError = "Please enter both username and password" return@TextButton } isLoggingIn = true loginError = null coroutineScope.launch(Dispatchers.IO) { try { LastFM.getMobileSession(tempUsername, tempPassword) .onSuccess { auth -> lastfmUsername = auth.session.name lastfmSession = auth.session.key LastFM.sessionKey = auth.session.key // Switch back to main thread to update UI coroutineScope.launch(Dispatchers.Main) { isLoggingIn = false showLoginDialog = false loginError = null } } .onFailure { exception -> coroutineScope.launch(Dispatchers.Main) { isLoggingIn = false loginError = when (exception) { is LastFM.LastFmException -> { when (exception.code) { 4 -> "Invalid username or password" 6 -> "Invalid parameters" 9 -> "Invalid session key" 10 -> "Invalid API key" 13 -> "Invalid method signature" 14 -> "Unauthorized token" 15 -> "Service temporarily unavailable" else -> "Login failed: ${exception.message}" } } else -> "Network error. Please check your connection." } } reportException(exception) } } catch (e: Exception) { coroutineScope.launch(Dispatchers.Main) { isLoggingIn = false loginError = "Unexpected error occurred" } reportException(e) } } }, enabled = !isLoggingIn ) { Text(stringResource(R.string.login)) } }, dismissButton = { TextButton( onClick = { if (!isLoggingIn) { showLoginDialog = false loginError = null } }, enabled = !isLoggingIn ) { Text(stringResource(R.string.cancel)) } } ) } Column( modifier = Modifier .windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom ) ) .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp) ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only( WindowInsetsSides.Top ) ) ) // Options section (card-based) Material3SettingsGroup( title = stringResource(R.string.account), items = listOf( Material3SettingsItem( title = { Text( text = if (isLoggedIn) lastfmUsername else stringResource(R.string.not_logged_in), modifier = Modifier.alpha(if (isLoggedIn) 1f else 0.5f), ) }, trailingContent = { if (isLoggedIn) { OutlinedButton(onClick = { lastfmSession = "" lastfmUsername = "" }) { Text(stringResource(R.string.action_logout)) } } else { OutlinedButton(onClick = { showLoginDialog = true }) { Text(stringResource(R.string.action_login)) } } }, icon = painterResource(R.drawable.music_note) ), ) ) Spacer(Modifier.height(8.dp)) Material3SettingsGroup( title = stringResource(R.string.options), items = listOf( Material3SettingsItem( title = { Text(stringResource(R.string.enable_scrobbling)) }, trailingContent = { Switch( checked = lastfmScrobbling, onCheckedChange = onlastfmScrobblingChange, enabled = isLoggedIn, ) }, enabled = isLoggedIn, icon = painterResource(R.drawable.queue_music) ), Material3SettingsItem( title = { Text(stringResource(R.string.lastfm_now_playing)) }, trailingContent = { Switch( checked = useNowPlaying, onCheckedChange = onUseNowPlayingChange, enabled = isLoggedIn && lastfmScrobbling, ) }, enabled = isLoggedIn && lastfmScrobbling, icon = painterResource(R.drawable.play) ), Material3SettingsItem( title = { Text(stringResource(R.string.last_fm_send_likes)) }, description = { stringResource(R.string.last_fm_send_likes_description) }, trailingContent = { Switch( checked = useSendLikes, onCheckedChange = onUseSendLikes, enabled = isLoggedIn, ) }, enabled = isLoggedIn, icon = painterResource(R.drawable.media3_icon_thumb_up_unfilled) ) ) ) var showMinTrackDurationDialog by rememberSaveable { mutableStateOf(false) } if (showMinTrackDurationDialog) { var tempMinTrackDuration by remember { mutableIntStateOf(minTrackDuration) } DefaultDialog( onDismiss = { tempMinTrackDuration = minTrackDuration showMinTrackDurationDialog = false }, buttons = { TextButton( onClick = { tempMinTrackDuration = LastFM.DEFAULT_SCROBBLE_MIN_SONG_DURATION } ) { Text(stringResource(R.string.reset)) } Spacer(modifier = Modifier.weight(1f)) TextButton( onClick = { tempMinTrackDuration = minTrackDuration showMinTrackDurationDialog = false } ) { Text(stringResource(android.R.string.cancel)) } TextButton( onClick = { onMinTrackDurationChange(tempMinTrackDuration) showMinTrackDurationDialog = false } ) { Text(stringResource(android.R.string.ok)) } } ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { Text( text = stringResource(R.string.scrobble_min_track_duration), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp) ) Text( text = makeTimeString((tempMinTrackDuration * 1000).toLong()), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(bottom = 16.dp) ) Slider( value = tempMinTrackDuration.toFloat(), onValueChange = { tempMinTrackDuration = it.toInt() }, valueRange = 10f..60f, modifier = Modifier.fillMaxWidth() ) } } } var showScrobbleDelayPercentDialog by rememberSaveable { mutableStateOf(false) } if (showScrobbleDelayPercentDialog) { var tempScrobbleDelayPercent by remember { mutableFloatStateOf(scrobbleDelayPercent) } DefaultDialog( onDismiss = { tempScrobbleDelayPercent = scrobbleDelayPercent showScrobbleDelayPercentDialog = false }, buttons = { TextButton( onClick = { tempScrobbleDelayPercent = LastFM.DEFAULT_SCROBBLE_DELAY_PERCENT } ) { Text(stringResource(R.string.reset)) } Spacer(modifier = Modifier.weight(1f)) TextButton( onClick = { tempScrobbleDelayPercent = scrobbleDelayPercent showScrobbleDelayPercentDialog = false } ) { Text(stringResource(android.R.string.cancel)) } TextButton( onClick = { onScrobbleDelayPercentChange(tempScrobbleDelayPercent) showScrobbleDelayPercentDialog = false } ) { Text(stringResource(android.R.string.ok)) } } ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { Text( text = stringResource(R.string.scrobble_delay_percent), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp) ) Text( text = stringResource(R.string.sensitivity_percentage, (tempScrobbleDelayPercent * 100).roundToInt()), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(bottom = 16.dp) ) Slider( value = tempScrobbleDelayPercent, onValueChange = { tempScrobbleDelayPercent = it }, valueRange = 0.3f..0.95f, modifier = Modifier.fillMaxWidth() ) } } } var showScrobbleDelaySecondsDialog by rememberSaveable { mutableStateOf(false) } if (showScrobbleDelaySecondsDialog) { var tempScrobbleDelaySeconds by remember { mutableIntStateOf(scrobbleDelaySeconds) } DefaultDialog( onDismiss = { tempScrobbleDelaySeconds = scrobbleDelaySeconds showScrobbleDelaySecondsDialog = false }, buttons = { TextButton( onClick = { tempScrobbleDelaySeconds = LastFM.DEFAULT_SCROBBLE_DELAY_SECONDS } ) { Text(stringResource(R.string.reset)) } Spacer(modifier = Modifier.weight(1f)) TextButton( onClick = { tempScrobbleDelaySeconds = scrobbleDelaySeconds showScrobbleDelaySecondsDialog = false } ) { Text(stringResource(android.R.string.cancel)) } TextButton( onClick = { onScrobbleDelaySecondsChange(tempScrobbleDelaySeconds) showScrobbleDelaySecondsDialog = false } ) { Text(stringResource(android.R.string.ok)) } } ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { Text( text = stringResource(R.string.scrobble_delay_minutes), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp) ) Text( text = makeTimeString((tempScrobbleDelaySeconds * 1000).toLong()), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(bottom = 16.dp) ) Slider( value = tempScrobbleDelaySeconds.toFloat(), onValueChange = { tempScrobbleDelaySeconds = it.toInt() }, valueRange = 30f..360f, modifier = Modifier.fillMaxWidth() ) } } } Spacer(Modifier.height(8.dp)) Material3SettingsGroup( title = stringResource(R.string.scrobbling_configuration), items = listOf( Material3SettingsItem( title = { Text(stringResource(R.string.scrobble_min_track_duration)) }, description = { Text(makeTimeString((minTrackDuration * 1000).toLong())) }, onClick = { showMinTrackDurationDialog = true }, icon = painterResource(R.drawable.timer) ), Material3SettingsItem( title = { Text(stringResource(R.string.scrobble_delay_percent)) }, description = { Text(stringResource(R.string.sensitivity_percentage, (scrobbleDelayPercent * 100).roundToInt())) }, onClick = { showScrobbleDelayPercentDialog = true }, icon = painterResource(R.drawable.timer) ), Material3SettingsItem( title = { Text(stringResource(R.string.scrobble_delay_minutes)) }, description = { Text(makeTimeString((scrobbleDelaySeconds * 1000).toLong())) }, onClick = { showScrobbleDelaySecondsDialog = true }, icon = painterResource(R.drawable.timer) ), ) ) } TopAppBar( title = { Text(stringResource(R.string.lastfm_integration)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/settings/integrations/ListenTogetherSettings.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.settings.integrations import android.widget.Toast import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState 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.Button 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.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.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.Color 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.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import com.metrolist.music.LocalPlayerAwareWindowInsets import com.metrolist.music.R import com.metrolist.music.constants.ListenTogetherAutoApprovalKey import com.metrolist.music.constants.ListenTogetherAutoApproveSuggestionsKey import com.metrolist.music.constants.ListenTogetherServerUrlKey import com.metrolist.music.constants.ListenTogetherSyncVolumeKey import com.metrolist.music.constants.ListenTogetherUsernameKey import com.metrolist.music.listentogether.ConnectionState import com.metrolist.music.listentogether.ListenTogetherEvent import com.metrolist.music.listentogether.ListenTogetherServer import com.metrolist.music.listentogether.ListenTogetherServers import com.metrolist.music.listentogether.LogEntry import com.metrolist.music.listentogether.LogLevel import com.metrolist.music.listentogether.RoomRole import com.metrolist.music.ui.component.DefaultDialog import com.metrolist.music.ui.component.IconButton import com.metrolist.music.ui.component.IntegrationCard import com.metrolist.music.ui.component.IntegrationCardItem import com.metrolist.music.ui.utils.backToMain import com.metrolist.music.utils.rememberPreference import com.metrolist.music.viewmodels.ListenTogetherViewModel import kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class) @Composable fun ListenTogetherSettings( navController: NavController, viewModel: ListenTogetherViewModel = hiltViewModel(), ) { val context = LocalContext.current val cannotEditUsernameInRoomStr = stringResource(R.string.listen_together_cannot_edit_username_in_room) val coroutineScope = rememberCoroutineScope() val connectionState by viewModel.connectionState.collectAsState() val roomState by viewModel.roomState.collectAsState() val role by viewModel.role.collectAsState() val pendingJoinRequests by viewModel.pendingJoinRequests.collectAsState() val logs by viewModel.logs.collectAsState() val blockedUsernames by viewModel.blockedUsernames.collectAsState() val servers = remember { ListenTogetherServers.servers } var serverUrl by rememberPreference(ListenTogetherServerUrlKey, ListenTogetherServers.defaultServerUrl) var username by rememberPreference(ListenTogetherUsernameKey, "") var autoApprovalJoins by rememberPreference(ListenTogetherAutoApprovalKey, false) var autoApproveSuggestions by rememberPreference(ListenTogetherAutoApproveSuggestionsKey, false) var syncHostVolume by rememberPreference(ListenTogetherSyncVolumeKey, true) var showServerUrlDialog by rememberSaveable { mutableStateOf(false) } var showUsernameDialog by rememberSaveable { mutableStateOf(false) } var showCreateRoomDialog by rememberSaveable { mutableStateOf(false) } var showJoinRoomDialog by rememberSaveable { mutableStateOf(false) } var showLogsDialog by rememberSaveable { mutableStateOf(false) } var showBlockedUsersDialog by rememberSaveable { mutableStateOf(false) } var roomCodeInput by rememberSaveable { mutableStateOf("") } // Handle events LaunchedEffect(Unit) { viewModel.events.collectLatest { event -> when (event) { is ListenTogetherEvent.RoomCreated -> { // Room created toast is shown globally by the client } is ListenTogetherEvent.JoinApproved -> { Toast.makeText(context, "Joined room: ${event.roomCode}", Toast.LENGTH_SHORT).show() } is ListenTogetherEvent.JoinRejected -> { Toast.makeText(context, "Join rejected: ${event.reason}", Toast.LENGTH_SHORT).show() } is ListenTogetherEvent.JoinRequestReceived -> { Toast.makeText(context, "${event.username} wants to join", Toast.LENGTH_SHORT).show() } is ListenTogetherEvent.Kicked -> { Toast.makeText(context, "Kicked: ${event.reason}", Toast.LENGTH_SHORT).show() } is ListenTogetherEvent.ConnectionError -> { Toast.makeText(context, "Connection error: ${event.error}", Toast.LENGTH_SHORT).show() } is ListenTogetherEvent.ServerError -> { Toast.makeText(context, "Error: ${event.message}", Toast.LENGTH_SHORT).show() } else -> {} } } } // Dialogs if (showServerUrlDialog) { ServerChooserDialog( servers = servers, currentUrl = serverUrl, onSelect = { server -> serverUrl = server.url showServerUrlDialog = false }, onUseCustom = { customUrl -> serverUrl = customUrl showServerUrlDialog = false }, onDismiss = { showServerUrlDialog = false }, ) } if (showUsernameDialog) { var tempUsername by rememberSaveable(showUsernameDialog) { mutableStateOf(username) } DefaultDialog( onDismiss = { showUsernameDialog = false }, icon = { Icon(painterResource(R.drawable.person), contentDescription = null) }, title = { Text(stringResource(R.string.listen_together_username)) }, buttons = { TextButton(onClick = { username = "" showUsernameDialog = false }) { Text(stringResource(R.string.reset)) } Spacer(modifier = Modifier.width(8.dp)) Button(onClick = { username = tempUsername.trim() showUsernameDialog = false }) { Text(stringResource(android.R.string.ok)) } }, ) { OutlinedTextField( value = tempUsername, onValueChange = { tempUsername = it }, label = { Text(stringResource(R.string.listen_together_username)) }, leadingIcon = { Icon(painterResource(R.drawable.person), contentDescription = null) }, trailingIcon = { if (tempUsername.isNotBlank()) { IconButton(onClick = { tempUsername = "" }, onLongClick = {}) { Icon(painterResource(R.drawable.close), contentDescription = null) } } }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) } } if (showCreateRoomDialog) { var createUsername by rememberSaveable(showCreateRoomDialog) { mutableStateOf(username) } DefaultDialog( onDismiss = { showCreateRoomDialog = false }, icon = { Icon(painterResource(R.drawable.add), contentDescription = null) }, title = { Text(stringResource(R.string.listen_together_create_room)) }, buttons = { TextButton(onClick = { showCreateRoomDialog = false }) { Text(stringResource(android.R.string.cancel)) } Spacer(modifier = Modifier.width(8.dp)) Button( onClick = { val finalUsername = createUsername.trim() if (finalUsername.isNotBlank()) { username = finalUsername viewModel.createRoom(finalUsername) showCreateRoomDialog = false } else { Toast.makeText(context, R.string.error_username_empty, Toast.LENGTH_SHORT).show() } }, enabled = createUsername.trim().isNotBlank(), ) { Text(stringResource(R.string.create)) } }, ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( text = stringResource(R.string.listen_together_create_room_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) OutlinedTextField( value = createUsername, onValueChange = { createUsername = it }, label = { Text(stringResource(R.string.listen_together_username)) }, leadingIcon = { Icon(painterResource(R.drawable.person), contentDescription = null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) } } } if (showJoinRoomDialog) { var joinUsername by rememberSaveable(showJoinRoomDialog) { mutableStateOf(username) } DefaultDialog( onDismiss = { showJoinRoomDialog = false }, icon = { Icon(painterResource(R.drawable.group_add), contentDescription = null) }, title = { Text(stringResource(R.string.listen_together_join_room)) }, buttons = { TextButton(onClick = { showJoinRoomDialog = false }) { Text(stringResource(android.R.string.cancel)) } Spacer(modifier = Modifier.width(8.dp)) Button( onClick = { val finalUsername = joinUsername.trim() if (finalUsername.isNotBlank() && roomCodeInput.length == 8) { username = finalUsername viewModel.joinRoom(roomCodeInput, finalUsername) showJoinRoomDialog = false roomCodeInput = "" } else { Toast.makeText(context, R.string.error_username_empty, Toast.LENGTH_SHORT).show() } }, enabled = joinUsername.trim().isNotBlank() && roomCodeInput.length == 8, ) { Text(stringResource(R.string.join)) } }, ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedTextField( value = joinUsername, onValueChange = { joinUsername = it }, label = { Text(stringResource(R.string.listen_together_username)) }, leadingIcon = { Icon(painterResource(R.drawable.person), contentDescription = null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = roomCodeInput, onValueChange = { roomCodeInput = it.uppercase().filter { c -> c.isLetterOrDigit() }.take(8) }, label = { Text(stringResource(R.string.listen_together_room_code)) }, leadingIcon = { Icon(painterResource(R.drawable.key), contentDescription = null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) } } } if (showLogsDialog) { LogsDialog( logs = logs, onClear = { viewModel.clearLogs() }, onDismiss = { showLogsDialog = false }, ) } if (showBlockedUsersDialog) { BlockedUsersDialog( blockedUsernames = blockedUsernames, onUnblock = { viewModel.unblockUser(it) }, onDismiss = { showBlockedUsersDialog = false }, ) } Column( Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) .verticalScroll(rememberScrollState()), ) { Spacer( Modifier.windowInsetsPadding( LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top), ), ) // Settings section using IntegrationCard val selectedServer = remember(serverUrl) { ListenTogetherServers.findByUrl(serverUrl) } Column(modifier = Modifier.padding(horizontal = 16.dp)) { IntegrationCard( title = stringResource(R.string.settings), items = listOf( IntegrationCardItem( icon = painterResource(R.drawable.person), title = { Text(stringResource(R.string.listen_together_blocked_users)) }, description = { Text( if (blockedUsernames.isNotEmpty()) { stringResource(R.string.listen_together_blocked_users_count, blockedUsernames.size) } else { stringResource(R.string.listen_together_no_blocked_users) }, ) }, onClick = if (blockedUsernames.isNotEmpty()) { { showBlockedUsersDialog = true } } else { null }, ), IntegrationCardItem( icon = painterResource(R.drawable.cloud), title = { Text(stringResource(R.string.listen_together_server_url)) }, description = { Text( selectedServer?.let { server -> "${server.name} - ${server.location}" } ?: serverUrl, maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, onClick = { showServerUrlDialog = true }, ), IntegrationCardItem( icon = painterResource(R.drawable.person), title = { Text(stringResource(R.string.listen_together_username)) }, description = { Text(username.ifEmpty { stringResource(R.string.not_set) }) }, onClick = if (roomState == null) { { showUsernameDialog = true } } else { { Toast .makeText( context, cannotEditUsernameInRoomStr, Toast.LENGTH_SHORT, ).show() } }, ), IntegrationCardItem( icon = painterResource(R.drawable.done), title = { Text(stringResource(R.string.listen_together_auto_approval_joins)) }, description = { Text(stringResource(R.string.listen_together_auto_approval_joins_desc)) }, trailingContent = { Switch( checked = autoApprovalJoins, onCheckedChange = { autoApprovalJoins = it }, // Only disable for guests in a room (hosts can always change) enabled = roomState == null || role != RoomRole.GUEST, thumbContent = { Icon( painter = painterResource( id = if (autoApprovalJoins) R.drawable.check else R.drawable.close, ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) }, ) }, // Allow clicking to see disabled state, but only change if enabled onClick = { if (roomState == null || role != RoomRole.GUEST) autoApprovalJoins = !autoApprovalJoins }, ), IntegrationCardItem( icon = painterResource(R.drawable.done), title = { Text(stringResource(R.string.listen_together_auto_approval_suggestions)) }, description = { Text(stringResource(R.string.listen_together_auto_approval_suggestions_desc)) }, trailingContent = { Switch( checked = autoApproveSuggestions, onCheckedChange = { autoApproveSuggestions = it }, // Only disable for guests in a room (hosts can always change) enabled = roomState == null || role != RoomRole.GUEST, thumbContent = { Icon( painter = painterResource( id = if (autoApproveSuggestions) R.drawable.check else R.drawable.close, ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) }, ) }, // Allow clicking to see disabled state, but only change if enabled onClick = { if (roomState == null || role != RoomRole.GUEST) autoApproveSuggestions = !autoApproveSuggestions }, ), IntegrationCardItem( icon = painterResource(R.drawable.volume_up), title = { Text(stringResource(R.string.listen_together_sync_volume)) }, description = { Text(stringResource(R.string.listen_together_sync_volume_desc)) }, trailingContent = { Switch( checked = syncHostVolume, onCheckedChange = { syncHostVolume = it }, thumbContent = { Icon( painter = painterResource( id = if (syncHostVolume) R.drawable.check else R.drawable.close, ), contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize), ) }, ) }, onClick = { syncHostVolume = !syncHostVolume }, ), IntegrationCardItem( icon = painterResource(R.drawable.bug_report), title = { Text(stringResource(R.string.listen_together_view_logs)) }, description = { Text(stringResource(R.string.listen_together_view_logs_desc)) }, onClick = { showLogsDialog = true }, ), ), ) } Spacer(modifier = Modifier.height(16.dp)) } TopAppBar( title = { Text(stringResource(R.string.listen_together)) }, navigationIcon = { IconButton( onClick = navController::navigateUp, onLongClick = navController::backToMain, ) { Icon( painterResource(R.drawable.arrow_back), contentDescription = null, ) } }, ) } @Composable fun LogsDialog( logs: List, onClear: () -> Unit, onDismiss: () -> Unit, ) { val listState = rememberLazyListState() LaunchedEffect(logs.size) { if (logs.isNotEmpty()) { listState.animateScrollToItem(logs.size - 1) } } val context = LocalContext.current DefaultDialog( onDismiss = onDismiss, icon = { Icon(painterResource(R.drawable.bug_report), contentDescription = null) }, title = { Text(stringResource(R.string.listen_together_logs)) }, buttons = { TextButton( onClick = { val textToCopy = logs.joinToString("\n") { log -> buildString { append(log.timestamp) append(" [") append(log.level.name) append("] ") append(log.message) log.details?.let { d -> append(" -- $d") } } } val cm = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clip = android.content.ClipData.newPlainText("ListenTogetherLogs", textToCopy) cm.setPrimaryClip(clip) Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show() }, enabled = logs.isNotEmpty(), ) { Text(stringResource(R.string.copy)) } TextButton(onClick = onClear) { Text(stringResource(R.string.clear)) } Spacer(modifier = Modifier.width(8.dp)) Button(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) } }, ) { Box( modifier = Modifier .fillMaxWidth() .height(350.dp), ) { if (logs.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Text( text = stringResource(R.string.listen_together_no_logs), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } else { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), ) { items(logs) { log -> LogEntryItem(log) } } } } } } @Composable private fun ServerChooserDialog( servers: List, currentUrl: String, onSelect: (ListenTogetherServer) -> Unit, onUseCustom: (String) -> Unit, onDismiss: () -> Unit, ) { var customUrl by rememberSaveable(currentUrl) { mutableStateOf(currentUrl) } val trimmedCustomUrl = customUrl.trim() DefaultDialog( onDismiss = onDismiss, icon = { Icon(painterResource(R.drawable.cloud), contentDescription = null) }, title = { Text(stringResource(R.string.listen_together_choose_server)) }, buttons = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } }, ) { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { servers.forEach { server -> val isSelected = server.url == currentUrl Card( modifier = Modifier .fillMaxWidth() .clickable { onSelect(server) }, shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( containerColor = if (isSelected) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceVariant }, ), ) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { Text( text = server.name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) Text( text = "${server.location} - ${server.operator}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = server.url, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } if (isSelected) { Icon( painter = painterResource(R.drawable.done), contentDescription = null, tint = MaterialTheme.colorScheme.primary, ) } } } } HorizontalDivider() Text( text = stringResource(R.string.listen_together_custom_server), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) OutlinedTextField( value = customUrl, onValueChange = { customUrl = it }, label = { Text(stringResource(R.string.listen_together_server_url)) }, leadingIcon = { Icon(painterResource(R.drawable.link), contentDescription = null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) Button( onClick = { onUseCustom(trimmedCustomUrl) }, enabled = trimmedCustomUrl.isNotBlank(), modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), ) { Text(stringResource(R.string.listen_together_use_custom_server)) } } } } @Composable fun LogEntryItem(log: LogEntry) { val context = LocalContext.current Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 2.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { Text( text = log.timestamp, style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(8.dp)) Surface( shape = RoundedCornerShape(8.dp), color = when (log.level) { LogLevel.ERROR -> MaterialTheme.colorScheme.errorContainer LogLevel.WARNING -> Color(0xFFFFF3CD) LogLevel.DEBUG -> MaterialTheme.colorScheme.surfaceVariant LogLevel.INFO -> MaterialTheme.colorScheme.primaryContainer }, ) { Text( text = log.level.name, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), color = when (log.level) { LogLevel.ERROR -> MaterialTheme.colorScheme.onErrorContainer LogLevel.WARNING -> Color(0xFF856404) LogLevel.DEBUG -> MaterialTheme.colorScheme.onSurfaceVariant LogLevel.INFO -> MaterialTheme.colorScheme.onPrimaryContainer }, ) } } Text( text = log.message, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, ) log.details?.let { details -> Text( text = details, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } } } @Composable fun BlockedUsersDialog( blockedUsernames: Set, onUnblock: (String) -> Unit, onDismiss: () -> Unit, ) { val listState = rememberLazyListState() DefaultDialog( onDismiss = onDismiss, icon = { Icon(painterResource(R.drawable.person), contentDescription = null) }, title = { Text(stringResource(R.string.listen_together_blocked_users)) }, buttons = { Button(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) } }, ) { Box( modifier = Modifier .fillMaxWidth() .height(280.dp), ) { if (blockedUsernames.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { Text( text = stringResource(R.string.listen_together_no_blocked_users), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } else { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(blockedUsernames.toList()) { username -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f), ) { Icon( painter = painterResource(R.drawable.person), contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = username, style = MaterialTheme.typography.bodyMedium, ) } TextButton( onClick = { onUnblock(username) }, ) { Text(stringResource(R.string.unblock)) } } } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/WrappedAudioService.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped import android.content.Context import android.net.ConnectivityManager import android.net.Uri import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import com.metrolist.music.R import com.metrolist.music.constants.AudioQuality import com.metrolist.music.utils.YTPlayerUtils import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber class WrappedAudioService( private val context: Context, ) { private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager private var player: ExoPlayer? = null private var playbackJob: Job? = null private val _isMuted = MutableStateFlow(false) val isMuted = _isMuted.asStateFlow() private fun initPlayer() { if (player == null) { player = ExoPlayer.Builder(context).build().apply { addListener(object : Player.Listener { override fun onPlayerError(error: androidx.media3.common.PlaybackException) { Timber.tag("WrappedAudioService").e(error, "Player error") playbackJob?.cancel() } }) } } } fun toggleMute() { _isMuted.value = !_isMuted.value player?.volume = if (_isMuted.value) 0f else 1f } suspend fun prepareTrack(songId: String?) { initPlayer() val songUri = getSongUri(songId) withContext(Dispatchers.Main) { val mediaItem = MediaItem.Builder() .setUri(songUri) .setMediaId(songId ?: "fallback") .build() player?.setMediaItem(mediaItem) player?.prepare() } } fun playTrack(songId: String?) { if (player?.currentMediaItem?.mediaId == songId) { Timber.tag("WrappedAudioService").d("Track $songId is already loaded or playing.") if (player?.isPlaying == false) player?.play() return } playbackJob?.cancel() playbackJob = scope.launch { try { prepareTrack(songId) withContext(Dispatchers.Main) { if (songId != null && songId != "2-p9DM2Xvsc") { player?.seekTo(30_000) } else { player?.seekTo(0) } player?.play() player?.volume = if (_isMuted.value) 0f else 1f } } catch (e: Exception) { Timber.tag("WrappedAudioService").e(e, "Error during playback preparation") } } } private suspend fun getSongUri(songId: String?): Uri { val fallbackUri = "android.resource://${context.packageName}/${R.raw.wrapped_theme}".toUri() if (songId == null) { Timber.tag("WrappedAudio").i("No song ID provided, using fallback audio.") return fallbackUri } return try { val audioQuality = context.dataStore.get(com.metrolist.music.constants.AudioQualityKey).let { AudioQuality.valueOf(it ?: AudioQuality.AUTO.name) } val playbackData = withContext(Dispatchers.IO) { YTPlayerUtils.playerResponseForPlayback( videoId = songId, audioQuality = audioQuality, connectivityManager = connectivityManager, ).getOrNull() } val streamUrl = playbackData?.streamUrl if (streamUrl.isNullOrBlank()) { Timber.tag("WrappedAudio") .w("Resolved URL for $songId is null or blank. Using fallback.") fallbackUri } else { streamUrl.toUri() } } catch (e: Exception) { Timber.tag("WrappedAudio").e(e, "Failed to resolve URL for $songId. Using fallback.") fallbackUri } } fun pause() { player?.pause() } fun resume() { player?.play() } fun release() { playbackJob?.cancel() player?.release() player = null Timber.tag("WrappedAudioService").d("Player released.") } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/WrappedConstants.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped object WrappedConstants { // This is intentionally hardcoded to 2025 and should not be changed. const val YEAR = 2025 const val PLAYLIST_NAME = "Metrolist 2025" } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/WrappedData.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped data class MessagePair(val range: LongRange, val tease: String, val reveal: String) object WrappedRepository { private val messages = listOf( MessagePair(0L..999L, "I really hope you are not dissapointed...", "That's **%d minutes**. Just warming up?"), MessagePair(0L..999L, "Testing the waters, are we?", "**%d minutes** is a quick dip in the musical ocean."), MessagePair(0L..999L, "Busy schedule this year?", "**%d minutes** is short, sweet, and to the point."), MessagePair(0L..999L, "Silence is golden, they say...", "But you preferred **%d minutes** of noise."), MessagePair(1000L..4999L, "It seems like you found Metrolist recently...", "And you dedicated **%d minutes** to the tunes."), MessagePair(1000L..4999L, "You have a life outside of music.", "**%d minutes** is a healthy balance. We respect that."), MessagePair(1000L..4999L, "Not too quiet, not too loud.", "Just the right amount of vibes for **%d minutes**."), MessagePair(1000L..4999L, "A casual stop on your journey.", "Thanks for dropping by for **%d minutes**."), MessagePair(5000L..14999L, "Music is definitely your thing.", "**%d minutes** is a solid soundtrack for your year."), MessagePair(5000L..14999L, "We saw you here quite a bit.", "Always setting the mood for **%d minutes**."), MessagePair(5000L..14999L, "Your commute must be fun.", "**%d minutes** of melodies."), MessagePair(5000L..14999L, "Consistent. Reliable. Rhythmic.", "You know what you like, for **%d minutes**."), MessagePair(15000L..39999L, "Do you ever take your headphones off?", "**%d minutes** suggests music is your oxygen."), MessagePair(15000L..39999L, "Your battery is begging for mercy.", "But your ears absolutely love those **%d minutes**."), MessagePair(15000L..39999L, "Main Character Energy detected.", "Your life was a movie for **%d minutes**."), MessagePair(15000L..39999L, "Walking, working, sleeping...", "There was always a song playing during those **%d minutes**."), MessagePair(40000L..Long.MAX_VALUE, "Are you... okay?", "You literally lived here for **%d minutes**."), MessagePair(40000L..Long.MAX_VALUE, "We are worried about your eardrums.", "Top 1% behavior. **%d minutes** is legendary."), MessagePair(40000L..Long.MAX_VALUE, "Silence scares you, doesn't it?", "A wall of sound, all year long, for **%d minutes**."), MessagePair(40000L..Long.MAX_VALUE, "Certified Stress Tester.", "You made those extractors work overtime for **%d minutes**.") ) fun getMessage(minutes: Long): MessagePair { val possibleMessages = messages.filter { minutes in it.range } val chosenMessage = if (possibleMessages.isNotEmpty()) { possibleMessages.random() } else { // Fallback for safety MessagePair(0L..Long.MAX_VALUE, "Looks like we lost count!", "But you definitely listened to **%d minutes** of music.") } return chosenMessage.copy( reveal = chosenMessage.reveal.format(minutes) ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/WrappedEntryPoint.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped import android.content.Context import androidx.compose.runtime.compositionLocalOf import com.metrolist.music.db.DatabaseDao import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent internal val LocalWrappedManager = compositionLocalOf { error("No WrappedManager found!") } @EntryPoint @InstallIn(SingletonComponent::class) internal interface WrappedEntryPoint { fun databaseDao(): DatabaseDao } internal fun provideWrappedManager(context: Context): WrappedManager { val entryPoint = EntryPointAccessors.fromApplication( context.applicationContext, WrappedEntryPoint::class.java ) return WrappedManager( databaseDao = entryPoint.databaseDao(), context = context.applicationContext ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/WrappedManager.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.AccountInfo import com.metrolist.music.constants.ArtistSongSortType import com.metrolist.music.db.DatabaseDao import com.metrolist.music.db.entities.Artist import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.SongWithStats import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import java.io.FileOutputStream import java.time.LocalDateTime import java.util.Calendar import java.util.UUID sealed class PlaylistCreationState { object Idle : PlaylistCreationState() object Creating : PlaylistCreationState() object Success : PlaylistCreationState() } class WrappedManager( private val databaseDao: DatabaseDao, private val context: Context ) { private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val _state = MutableStateFlow(WrappedState()) val state = _state.asStateFlow() fun createPlaylist(imageResName: String) { if (_state.value.playlistCreationState != PlaylistCreationState.Idle) return _state.update { it.copy(playlistCreationState = PlaylistCreationState.Creating) } scope.launch { try { withContext(Dispatchers.IO) { val fromTimestamp = Calendar.getInstance().apply { set(WrappedConstants.YEAR, Calendar.JANUARY, 1, 0, 0, 0) }.timeInMillis val toTimestamp = Calendar.getInstance().apply { set(WrappedConstants.YEAR, Calendar.DECEMBER, 31, 23, 59, 59) }.timeInMillis val allSongs = databaseDao.mostPlayedSongsStats(fromTimestamp, toTimeStamp = toTimestamp, limit = -1).first() val playlistId = UUID.randomUUID().toString() val drawableId = context.resources.getIdentifier(imageResName, "drawable", context.packageName) val bitmap = BitmapFactory.decodeResource(context.resources, drawableId) val file = File(context.cacheDir, "$playlistId.png") FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } val newPlaylist = PlaylistEntity( id = playlistId, name = WrappedConstants.PLAYLIST_NAME, thumbnailUrl = file.toURI().toString(), bookmarkedAt = LocalDateTime.now(), isEditable = true ) databaseDao.insert(newPlaylist) val createdPlaylist = databaseDao.playlist(playlistId).first() if (createdPlaylist != null) { val songIds = allSongs.map { it.id } databaseDao.addSongToPlaylist(createdPlaylist, songIds) } else { Timber.tag("WrappedManager") .e("Failed to retrieve created playlist with id: $playlistId") } } _state.update { it.copy(playlistCreationState = PlaylistCreationState.Success) } } catch (e: Exception) { Timber.tag("WrappedManager").e(e, "Error saving wrapped playlist") _state.update { it.copy(playlistCreationState = PlaylistCreationState.Idle) } } } } private suspend fun generatePlaylistMap() { val topSongs = _state.value.topSongs val topArtists = _state.value.topArtists if (topSongs.isEmpty()) { Timber.tag("WrappedManager").w("Cannot generate playlist map, top songs list is empty.") _state.update { it.copy(trackMap = emptyMap()) } return } withContext(Dispatchers.IO) { val playlistMap = mutableMapOf() // Intro Part: Random song from top 6-30 val introSongPool = topSongs.subList(5, topSongs.size) val introSong = introSongPool.randomOrNull()?.id ?: topSongs.last().id playlistMap[WrappedScreenType.Welcome] = introSong playlistMap[WrappedScreenType.MinutesTease] = introSong playlistMap[WrappedScreenType.MinutesReveal] = introSong // Music Part: Top 1 song val topSong = topSongs.first() playlistMap[WrappedScreenType.TotalSongs] = topSong.id playlistMap[WrappedScreenType.TopSongReveal] = topSong.id playlistMap[WrappedScreenType.Top5Songs] = topSong.id // Album Part: Random song from top album val topAlbum = _state.value.topAlbum val albumSong = topAlbum?.let { album -> val albumSongs = databaseDao.albumSongs(album.id).first() albumSongs.randomOrNull()?.id } ?: topSong.id // Fallback to top song if no album songs playlistMap[WrappedScreenType.TotalAlbums] = albumSong playlistMap[WrappedScreenType.TopAlbumReveal] = albumSong playlistMap[WrappedScreenType.Top5Albums] = albumSong // Artist Part: Top artist's song with specific rule val topArtist = topArtists.firstOrNull() val fromTimestamp = Calendar.getInstance().apply { set(WrappedConstants.YEAR, Calendar.JANUARY, 1, 0, 0, 0) }.timeInMillis val toTimestamp = Calendar.getInstance().apply { set(WrappedConstants.YEAR, Calendar.DECEMBER, 31, 23, 59, 59) }.timeInMillis val artistSong = topArtist?.let { artist -> val artistTopSongs = databaseDao.artistSongs( artistId = artist.id, sortType = ArtistSongSortType.PLAY_TIME, descending = true, fromTimeStamp = fromTimestamp, toTimeStamp = toTimestamp ).first() if (artistTopSongs.isNotEmpty()) { val artistTopSong = artistTopSongs.first() if (artistTopSong.id == topSong.id) { // Overlap: Use the artist's second song. // If a second song doesn't exist, use a random song from their list. artistTopSongs.getOrNull(1)?.id ?: artistTopSongs.filter { it.id != topSong.id }.randomOrNull()?.id ?: artistTopSong.id } else { artistTopSong.id } } else { // Data anomaly: Fallback to the user's top song. topSong.id } } ?: topSong.id // Fallback if no top artist. playlistMap[WrappedScreenType.TotalArtists] = artistSong playlistMap[WrappedScreenType.TopArtistReveal] = artistSong playlistMap[WrappedScreenType.Top5Artists] = artistSong // End Part val endSongPool = topSongs.subList(2, 5) val endSong = endSongPool.randomOrNull()?.id ?: topSongs[2].id playlistMap[WrappedScreenType.Playlist] = endSong playlistMap[WrappedScreenType.Conclusion] = "2-p9DM2Xvsc" Timber.tag("WrappedManager").d("Generated Playlist Map: $playlistMap") _state.update { it.copy(trackMap = playlistMap) } } } suspend fun prepare() { if (_state.value.isDataReady) return Timber.tag("WrappedManager").d("Starting Wrapped data preparation") val fromTimestamp = Calendar.getInstance().apply { set(WrappedConstants.YEAR, Calendar.JANUARY, 1, 0, 0, 0) }.timeInMillis val toTimestamp = Calendar.getInstance().apply { set(WrappedConstants.YEAR, Calendar.DECEMBER, 31, 23, 59, 59) }.timeInMillis withContext(Dispatchers.IO) { val accountInfoDeferred = async { YouTube.accountInfo().getOrNull() } val topSongsDeferred = async { databaseDao.mostPlayedSongsStats(fromTimestamp, toTimeStamp = toTimestamp, limit = 30).first() } val topArtistsDeferred = async { databaseDao.mostPlayedArtists(fromTimestamp, toTimeStamp = toTimestamp, limit = 5).first() } val topAlbumsDeferred = async { databaseDao.mostPlayedAlbums(fromTimestamp, toTimeStamp = toTimestamp, limit = 5).first() } val uniqueSongCountDeferred = async { databaseDao.getUniqueSongCountInRange(fromTimestamp, toTimestamp).first() } val uniqueArtistCountDeferred = async { databaseDao.getUniqueArtistCountInRange(fromTimestamp, toTimestamp).first() } val uniqueAlbumCountDeferred = async { databaseDao.getUniqueAlbumCountInRange(fromTimestamp, toTimestamp).first() } val totalPlayTimeMsDeferred = async { databaseDao.getTotalPlayTimeInRange(fromTimestamp, toTimestamp).first() ?: 0L } val results = awaitAll( accountInfoDeferred, topSongsDeferred, topArtistsDeferred, topAlbumsDeferred, uniqueSongCountDeferred, uniqueArtistCountDeferred, uniqueAlbumCountDeferred, totalPlayTimeMsDeferred ) @Suppress("UNCHECKED_CAST") val topSongsResult = results[1] as List @Suppress("UNCHECKED_CAST") val topAlbumsResult = results[3] as List @Suppress("UNCHECKED_CAST") val topArtistsResult = results[2] as List _state.update { it.copy( accountInfo = results[0] as AccountInfo?, topSongs = topSongsResult, topArtists = topArtistsResult, top5Albums = topAlbumsResult, topAlbum = topAlbumsResult.firstOrNull(), uniqueSongCount = results[4] as Int, uniqueArtistCount = results[5] as Int, totalAlbums = results[6] as Int, totalMinutes = (results[7] as Long) / 1000 / 60 ) } } generatePlaylistMap() _state.update { it.copy(isDataReady = true) } Timber.tag("WrappedManager").d("Wrapped data preparation finished") } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/WrappedScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped import androidx.activity.compose.BackHandler import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults 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.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.NavController import com.metrolist.music.R import com.metrolist.music.ui.screens.wrapped.pages.ConclusionPage import com.metrolist.music.ui.screens.wrapped.pages.PlaylistPage import com.metrolist.music.ui.screens.wrapped.pages.WrappedIntro import com.metrolist.music.ui.screens.wrapped.pages.WrappedMinutesScreen import com.metrolist.music.ui.screens.wrapped.pages.WrappedMinutesTease import com.metrolist.music.ui.screens.wrapped.pages.WrappedTop5AlbumsScreen import com.metrolist.music.ui.screens.wrapped.pages.WrappedTop5ArtistsScreen import com.metrolist.music.ui.screens.wrapped.pages.WrappedTop5SongsScreen import com.metrolist.music.ui.screens.wrapped.pages.WrappedTopAlbumScreen import com.metrolist.music.ui.screens.wrapped.pages.WrappedTopArtistScreen import com.metrolist.music.ui.screens.wrapped.pages.WrappedTopSongScreen import com.metrolist.music.ui.screens.wrapped.pages.WrappedTotalAlbumsScreen import com.metrolist.music.ui.screens.wrapped.pages.WrappedTotalArtistsScreen import com.metrolist.music.ui.screens.wrapped.pages.WrappedTotalSongsScreen import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch sealed class WrappedScreenType { object Welcome : WrappedScreenType() object MinutesTease : WrappedScreenType() object MinutesReveal : WrappedScreenType() object TotalSongs : WrappedScreenType() object TopSongReveal : WrappedScreenType() object Top5Songs : WrappedScreenType() object TotalAlbums : WrappedScreenType() object TopAlbumReveal : WrappedScreenType() object Top5Albums : WrappedScreenType() object TotalArtists : WrappedScreenType() object TopArtistReveal : WrappedScreenType() object Top5Artists : WrappedScreenType() object Playlist : WrappedScreenType() object Conclusion : WrappedScreenType() } @Composable fun WrappedScreen(navController: NavController) { val context = LocalContext.current val manager = remember { provideWrappedManager(context) } CompositionLocalProvider(LocalWrappedManager provides manager) { WrappedScreenContent(navController = navController) } } @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun WrappedScreenContent(navController: NavController) { val onClose: () -> Unit = { navController.previousBackStackEntry?.savedStateHandle?.set("wrapped_seen", true) navController.popBackStack() } BackHandler(onBack = onClose) val messagePairSaver = Saver>( save = { listOf(it.range.first, it.range.last, it.tease, it.reveal) }, restore = { MessagePair( range = (it[0] as Long)..(it[1] as Long), tease = it[2] as String, reveal = it[3] as String, ) }, ) val view = LocalView.current val scope = rememberCoroutineScope() val manager = LocalWrappedManager.current val audioService = remember { WrappedAudioService(view.context) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(Unit) { val window = (view.context as android.app.Activity).window val insetsController = WindowCompat.getInsetsController(window, view) insetsController.hide(WindowInsetsCompat.Type.systemBars()) val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_PAUSE -> { audioService.pause() } Lifecycle.Event.ON_RESUME -> { audioService.resume() } else -> {} } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { insetsController.show(WindowInsetsCompat.Type.systemBars()) lifecycleOwner.lifecycle.removeObserver(observer) audioService.release() } } val screens = remember { listOf( WrappedScreenType.Welcome, WrappedScreenType.MinutesTease, WrappedScreenType.MinutesReveal, WrappedScreenType.TotalSongs, WrappedScreenType.TopSongReveal, WrappedScreenType.Top5Songs, WrappedScreenType.TotalAlbums, WrappedScreenType.TopAlbumReveal, WrappedScreenType.Top5Albums, WrappedScreenType.TotalArtists, WrappedScreenType.TopArtistReveal, WrappedScreenType.Top5Artists, WrappedScreenType.Playlist, WrappedScreenType.Conclusion, ) } val pagerState = rememberPagerState(pageCount = { screens.size }) val state by manager.state.collectAsState() val isMuted by audioService.isMuted.collectAsState() val messagePair = rememberSaveable(state.totalMinutes, saver = messagePairSaver) { WrappedRepository.getMessage(state.totalMinutes) } LaunchedEffect(Unit) { manager.prepare() } LaunchedEffect(pagerState, state.trackMap) { if (state.trackMap.isEmpty()) return@LaunchedEffect snapshotFlow { pagerState.currentPage }.distinctUntilChanged().collect { page -> val screen = screens.getOrNull(page) audioService.playTrack(state.trackMap[screen]) } } Scaffold( topBar = { TopAppBar( title = { }, navigationIcon = { IconButton(onClick = onClose) { Icon(painterResource(R.drawable.arrow_back), stringResource(R.string.back_button_desc), tint = Color.White) } }, actions = { IconButton(onClick = { audioService.toggleMute() }) { val icon = if (isMuted) R.drawable.volume_off else R.drawable.volume_up Icon(painterResource(icon), "Mute", tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), ) }, containerColor = Color.Black, ) { paddingValues -> VerticalPager( state = pagerState, modifier = Modifier .fillMaxSize() .padding(paddingValues), ) { page -> when (screens[page]) { is WrappedScreenType.Welcome -> { WrappedIntro { scope.launch { pagerState.animateScrollToPage(page = 1) } } } is WrappedScreenType.MinutesTease -> { WrappedMinutesTease( messagePair = messagePair, onNavigateForward = { scope.launch { pagerState.animateScrollToPage(page = 2) } }, isDataReady = state.isDataReady, ) } is WrappedScreenType.MinutesReveal -> { WrappedMinutesScreen( messagePair = messagePair, totalMinutes = state.totalMinutes, isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.MinutesReveal), ) } is WrappedScreenType.TotalSongs -> { WrappedTotalSongsScreen( uniqueSongCount = state.uniqueSongCount, isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.TotalSongs), ) } is WrappedScreenType.TopSongReveal -> { WrappedTopSongScreen( topSong = state.topSongs.firstOrNull(), isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.TopSongReveal), ) } is WrappedScreenType.Top5Songs -> { WrappedTop5SongsScreen( topSongs = state.topSongs.take(5), isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.Top5Songs), ) } is WrappedScreenType.TotalAlbums -> { WrappedTotalAlbumsScreen( uniqueAlbumCount = state.totalAlbums, isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.TotalAlbums), ) } is WrappedScreenType.TopAlbumReveal -> { WrappedTopAlbumScreen( topAlbum = state.topAlbum, isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.TopAlbumReveal), ) } is WrappedScreenType.Top5Albums -> { WrappedTop5AlbumsScreen( topAlbums = state.top5Albums, isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.Top5Albums), ) } is WrappedScreenType.TotalArtists -> { WrappedTotalArtistsScreen( uniqueArtistCount = state.uniqueArtistCount, isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.TotalArtists), ) } is WrappedScreenType.TopArtistReveal -> { WrappedTopArtistScreen( topArtist = state.topArtists.firstOrNull(), isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.TopArtistReveal), ) } is WrappedScreenType.Top5Artists -> { WrappedTop5ArtistsScreen( topArtists = state.topArtists, isVisible = pagerState.currentPage == screens.indexOf(WrappedScreenType.Top5Artists), ) } is WrappedScreenType.Playlist -> { PlaylistPage() } is WrappedScreenType.Conclusion -> { ConclusionPage(onClose = onClose) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/WrappedState.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped import com.metrolist.innertube.models.AccountInfo import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Artist import com.metrolist.music.db.entities.SongWithStats data class WrappedState( val accountInfo: AccountInfo? = null, val totalMinutes: Long = 0, val topSongs: List = emptyList(), val topArtists: List = emptyList(), val top5Albums: List = emptyList(), val topAlbum: Album? = null, val uniqueSongCount: Int = 0, val uniqueArtistCount: Int = 0, val totalAlbums: Int = 0, val isDataReady: Boolean = false, val trackMap: Map = emptyMap(), val playlistCreationState: PlaylistCreationState = PlaylistCreationState.Idle ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/WrappedViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class WrappedViewModel @Inject constructor() : ViewModel() ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/components/AnimatedBackground.kt ================================================ package com.metrolist.music.ui.screens.wrapped.components import androidx.compose.animation.core.LinearEasing 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.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import kotlin.random.Random enum class ShapeType { Circle, Rect, Line } private data class AnimatedElement( val shapeType: ShapeType, val initialX: Float, val initialY: Float, val targetX: Float, val targetY: Float, val size: Float, // radius for circle, width/height for rect, length multiplier for line val alpha: Float, val duration: Int ) @Composable internal fun AnimatedBackground( elementCount: Int = 20, shapeTypes: List = listOf(ShapeType.Circle) ) { val random = remember { Random(System.currentTimeMillis()) } val elements = remember { List(elementCount) { val shapeType = shapeTypes.random(random) AnimatedElement( shapeType = shapeType, initialX = random.nextFloat(), initialY = random.nextFloat(), targetX = random.nextFloat(), targetY = random.nextFloat(), size = if (shapeType == ShapeType.Circle) random.nextFloat() * 15f + 5f else random.nextFloat() * 50f + 10f, alpha = random.nextFloat() * 0.3f + 0.1f, duration = random.nextInt(4000, 10000) ) } } val infiniteTransition = rememberInfiniteTransition(label = "animated_bg") val progressAnims = elements.map { infiniteTransition.animateFloat( initialValue = 0f, targetValue = 1f, animationSpec = infiniteRepeatable( animation = tween(it.duration, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "element_progress" ) } Canvas(modifier = Modifier.fillMaxSize()) { elements.forEachIndexed { index, element -> val progress = progressAnims[index].value val currentX = element.initialX + (element.targetX - element.initialX) * progress val currentY = element.initialY + (element.targetY - element.initialY) * progress when (element.shapeType) { ShapeType.Circle -> { drawCircle( color = Color.White.copy(alpha = element.alpha), radius = element.size, center = Offset(currentX * size.width, currentY * size.height) ) } ShapeType.Rect -> { drawRect( color = Color.White.copy(alpha = element.alpha), topLeft = Offset(currentX * size.width, currentY * size.height), size = Size(element.size, element.size) ) } ShapeType.Line -> { val endX = currentX + (element.targetX - element.initialX) * 0.1f val endY = currentY + (element.targetY - element.initialY) * 0.1f drawLine( color = Color.White.copy(alpha = element.alpha), start = Offset(currentX * size.width, currentY * size.height), end = Offset(endX * size.width, endY * size.height), strokeWidth = 2f ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/components/AnimatedDecorativeElement.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlin.random.Random @Composable fun AnimatedDecorativeElement(modifier: Modifier = Modifier, isVisible: Boolean) { val rotation = remember { Animatable(0f) } val shapeType = remember { Random.nextInt(3) } LaunchedEffect(isVisible) { if (isVisible) { delay(Random.nextLong(500)) rotation.animateTo( targetValue = 360f, animationSpec = infiniteRepeatable( animation = tween(Random.nextInt(1000, 3000)), repeatMode = RepeatMode.Restart ) ) } } Canvas(modifier.graphicsLayer { rotationZ = rotation.value }) { val strokeWidth = 2.dp.toPx() when (shapeType) { 0 -> drawArc(Color.White.copy(0.2f), 0f, 90f, false, style = Stroke(strokeWidth)) 1 -> drawCircle(Color.White.copy(0.2f), style = Stroke(strokeWidth)) 2 -> drawRect(Color.White.copy(0.2f), style = Stroke(strokeWidth)) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/components/AutoResizingText.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.components 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.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.text.TextStyle @Composable fun AutoResizingText( text: String, modifier: Modifier = Modifier, style: TextStyle ) { var scaledTextStyle by remember { mutableStateOf(style) } var readyToDraw by remember { mutableStateOf(false) } Text( text = text, style = scaledTextStyle, maxLines = 1, softWrap = false, modifier = modifier.drawWithContent { if (readyToDraw) { drawContent() } }, onTextLayout = { textLayoutResult -> if (textLayoutResult.didOverflowWidth) { scaledTextStyle = scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) } else { readyToDraw = true } } ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/AlbumPages.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInVertically 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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.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.graphics.drawscope.Stroke import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource 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.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.metrolist.music.R import com.metrolist.music.db.entities.Album import com.metrolist.music.ui.screens.wrapped.components.AnimatedBackground import com.metrolist.music.ui.screens.wrapped.components.ShapeType import com.metrolist.music.ui.theme.bbh_bartle import com.metrolist.music.ui.utils.resize import kotlinx.coroutines.delay @Composable fun WrappedTotalAlbumsScreen(uniqueAlbumCount: Int, isVisible: Boolean) { val animatedAlbums = remember { Animatable(0f) } val textMeasurer = rememberTextMeasurer() var visible by remember { mutableStateOf(false) } LaunchedEffect(isVisible, uniqueAlbumCount) { if (isVisible) { visible = true if (uniqueAlbumCount > 0) { animatedAlbums.animateTo( targetValue = uniqueAlbumCount.toFloat(), animationSpec = tween(1500, easing = FastOutSlowInEasing) ) } } } Box(modifier = Modifier.fillMaxSize()) { AnimatedBackground(shapeTypes = listOf(ShapeType.Circle)) Column( modifier = Modifier .fillMaxSize() .padding(vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 200)) + slideInVertically(animationSpec = tween(1000, delayMillis = 200)) ) { Text( text = stringResource(R.string.wrapped_total_albums_title), modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.headlineSmall.copy( color = Color.White, textAlign = TextAlign.Center ) ) } Spacer(Modifier.height(32.dp)) BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { val density = LocalDensity.current val baseStyle = MaterialTheme.typography.displayLarge.copy( color = Color.White, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, fontFamily = bbh_bartle, drawStyle = Stroke(with(density) { 2.dp.toPx() }) ) val textStyle = remember(uniqueAlbumCount, maxWidth) { val finalText = uniqueAlbumCount.toString() var style = baseStyle.copy(fontSize = 96.sp) var textWidth = textMeasurer.measure(finalText, style).size.width while (textWidth > constraints.maxWidth) { style = style.copy(fontSize = style.fontSize * 0.95f) textWidth = textMeasurer.measure(finalText, style).size.width } style.copy(lineHeight = style.fontSize * 1.08f) } Text( text = animatedAlbums.value.toInt().toString(), style = textStyle, maxLines = 1, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) } Spacer(Modifier.height(16.dp)) AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 600)) + slideInVertically(animationSpec = tween(1000, delayMillis = 600)) ) { Text( text = stringResource(R.string.wrapped_total_albums_subtitle), modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.bodyLarge.copy( color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center ) ) } } } } @Composable fun WrappedTopAlbumScreen(topAlbum: Album?, isVisible: Boolean) { var visible by remember { mutableStateOf(false) } LaunchedEffect(isVisible) { if (isVisible) { visible = true } } Box(modifier = Modifier.fillMaxSize()) { AnimatedBackground(shapeTypes = listOf(ShapeType.Rect)) Column( modifier = Modifier .fillMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 200)) + slideInVertically(animationSpec = tween(1000, delayMillis = 200)) ) { Text( text = stringResource(R.string.wrapped_top_album_title), style = TextStyle( fontFamily = bbh_bartle, fontSize = 40.sp, color = Color.White, textAlign = TextAlign.Center, lineHeight = 48.sp ) ) } Spacer(modifier = Modifier.height(32.dp)) AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 400)) + slideInVertically(animationSpec = tween(1000, delayMillis = 400)) ) { AsyncImage( model = topAlbum?.thumbnailUrl?.resize(512, 512), contentDescription = stringResource(R.string.album_art_for, topAlbum?.title ?: ""), modifier = Modifier .size(200.dp) .clip(RoundedCornerShape(3.dp)), contentScale = ContentScale.Crop ) } Spacer(modifier = Modifier.height(16.dp)) AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 600)) + slideInVertically(animationSpec = tween(1000, delayMillis = 600)) ) { Text( text = topAlbum?.title ?: stringResource(id = R.string.wrapped_no_data), fontSize = 24.sp, color = Color.White, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(8.dp)) AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 800)) + slideInVertically(animationSpec = tween(1000, delayMillis = 800)) ) { Text( text = stringResource(R.string.wrapped_album_listening_time, topAlbum?.timeListened?.div(60000) ?: 0), fontSize = 16.sp, color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center ) } } } } @Composable fun WrappedTop5AlbumsScreen(topAlbums: List, isVisible: Boolean) { var visible by remember { mutableStateOf(false) } LaunchedEffect(isVisible) { if (isVisible) { delay(200) visible = true } } Box(modifier = Modifier.fillMaxSize()) { AnimatedBackground(shapeTypes = listOf(ShapeType.Circle)) Column( modifier = Modifier .fillMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 200)) + slideInVertically(animationSpec = tween(1000, delayMillis = 200)) ) { Text( text = stringResource(R.string.wrapped_top_5_albums_title), style = TextStyle( fontFamily = bbh_bartle, fontSize = 48.sp, color = Color.White, textAlign = TextAlign.Center, lineHeight = 56.sp ) ) } Spacer(modifier = Modifier.height(32.dp)) Column { topAlbums.forEachIndexed { index, album -> AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(600, delayMillis = 400 + (index * 200))) + slideInVertically(animationSpec = tween(600, delayMillis = 400 + (index * 200))) ) { Row( modifier = Modifier .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = "${index + 1}", fontFamily = bbh_bartle, fontSize = 36.sp, color = Color.White.copy(alpha = 0.8f), modifier = Modifier.width(40.dp) ) Spacer(modifier = Modifier.width(16.dp)) AsyncImage( model = album.thumbnailUrl?.resize(128, 128), contentDescription = stringResource(R.string.album_art_for, album.title), modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(3.dp)), contentScale = ContentScale.Crop ) Spacer(modifier = Modifier.width(16.dp)) Column { Text( text = album.title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp, maxLines = 1 ) Text( text = stringResource(R.string.wrapped_album_listening_time_minutes, album.timeListened?.div(60000) ?: 0), color = Color.White.copy(alpha = 0.7f), fontSize = 14.sp ) } } } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/ConclusionPage.kt ================================================ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.foundation.layout.Arrangement 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.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon 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.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.metrolist.music.R import com.metrolist.music.ui.screens.wrapped.components.AnimatedBackground import com.metrolist.music.ui.screens.wrapped.components.ShapeType @Composable fun ConclusionPage(onClose: () -> Unit) { Box(modifier = Modifier.fillMaxSize()) { AnimatedBackground(elementCount = 30, shapeTypes = listOf(ShapeType.Circle, ShapeType.Line)) Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(id = R.drawable.ic_launcher_foreground), contentDescription = stringResource(R.string.wrapped_logo_content_description), modifier = Modifier.size(96.dp), tint = Color.White ) Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.wrapped_thank_you), style = TextStyle( fontSize = 28.sp, fontWeight = FontWeight.Bold, color = Color.White ) ) Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.wrapped_special_thanks), style = TextStyle( fontSize = 16.sp, color = Color.Gray ) ) Spacer(modifier = Modifier.height(48.dp)) Button( onClick = onClose, shape = CircleShape, colors = ButtonDefaults.buttonColors(containerColor = Color.White) ) { Text( text = stringResource(R.string.wrapped_close), style = TextStyle( color = Color.Black, fontWeight = FontWeight.Bold ) ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/PlaylistPage.kt ================================================ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement 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.foundation.layout.size 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.CircularProgressIndicator 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.draw.alpha 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.TextStyle 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 com.metrolist.music.R import com.metrolist.music.ui.screens.wrapped.LocalWrappedManager import com.metrolist.music.ui.screens.wrapped.PlaylistCreationState import com.metrolist.music.ui.screens.wrapped.WrappedConstants import com.metrolist.music.ui.screens.wrapped.components.AnimatedBackground import com.metrolist.music.ui.screens.wrapped.components.AutoResizingText import com.metrolist.music.ui.screens.wrapped.components.ShapeType import com.metrolist.music.ui.theme.bbh_bartle import kotlinx.coroutines.delay import kotlin.random.Random @Composable fun PlaylistPage() { val manager = LocalWrappedManager.current val state by manager.state.collectAsState() val playlistCreationState = state.playlistCreationState val (playlistImageRes, playlistImageName) = remember { if (Random.nextBoolean()) { Pair(R.drawable.wrapped_playlistv1, "wrapped_playlistv1") } else { Pair(R.drawable.wrapped_playlistv2, "wrapped_playlistv2") } } var startAnimation by remember { mutableStateOf(false) } LaunchedEffect(Unit) { delay(200) startAnimation = true } val contentAlpha by animateFloatAsState( targetValue = if (startAnimation) 1f else 0f, animationSpec = tween(durationMillis = 800, delayMillis = 200) ) Box(modifier = Modifier.fillMaxSize()) { AnimatedBackground(shapeTypes = listOf(ShapeType.Circle)) Column( modifier = Modifier .fillMaxSize() .padding(32.dp) .alpha(contentAlpha), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { AutoResizingText( text = stringResource(R.string.wrapped_playlist_ready), style = TextStyle( fontFamily = bbh_bartle, fontSize = 40.sp, color = Color.White, textAlign = TextAlign.Center, lineHeight = 48.sp ) ) Spacer(modifier = Modifier.height(32.dp)) Image( painter = painterResource(id = playlistImageRes), contentDescription = stringResource(R.string.album_cover_desc), modifier = Modifier .size(256.dp) .clip(RoundedCornerShape(3.dp)) ) Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.wrapped_playlist_title, WrappedConstants.YEAR), style = TextStyle( fontSize = 22.sp, fontWeight = FontWeight.Bold, color = Color.White ) ) Spacer(modifier = Modifier.height(48.dp)) Button( onClick = { if (playlistCreationState == PlaylistCreationState.Idle) { manager.createPlaylist(playlistImageName) } }, shape = CircleShape, colors = ButtonDefaults.buttonColors(containerColor = Color.White), modifier = Modifier.height(50.dp) ) { when (playlistCreationState) { is PlaylistCreationState.Idle -> Text( text = stringResource(R.string.wrapped_create_playlist), style = TextStyle(color = Color.Black, fontWeight = FontWeight.Bold) ) is PlaylistCreationState.Creating -> CircularProgressIndicator( modifier = Modifier.size(24.dp), color = Color.Black, strokeWidth = 2.dp ) is PlaylistCreationState.Success -> Text( text = stringResource(R.string.wrapped_playlist_saved), style = TextStyle(color = Color.Black, fontWeight = FontWeight.Bold) ) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedIntro.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing 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.fadeIn import androidx.compose.animation.slideInVertically import androidx.compose.foundation.background 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon 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.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle 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 com.metrolist.music.R import com.metrolist.music.ui.theme.bbhBartle import kotlinx.coroutines.delay private const val FADE_IN_DURATION = 1000 private const val SLIDE_IN_DURATION = 1000 private const val INITIAL_DELAY = 200 private const val ICON_DELAY = 200 private const val TITLE_DELAY = 400 private const val SUBTITLE_DELAY = 600 private const val BUTTON_DELAY = 1000 private val BOTTOM_PADDING = 64.dp @Composable fun AutoResizingText( text: String, modifier: Modifier = Modifier, style: TextStyle ) { var scaledTextStyle by remember { mutableStateOf(style) } var readyToDraw by remember { mutableStateOf(false) } Text( text = text, style = scaledTextStyle, maxLines = 1, softWrap = false, modifier = modifier.drawWithContent { if (readyToDraw) { drawContent() } }, onTextLayout = { textLayoutResult -> if (textLayoutResult.didOverflowWidth) { scaledTextStyle = scaledTextStyle.copy(fontSize = scaledTextStyle.fontSize * 0.9) } else { readyToDraw = true } } ) } @Composable fun WrappedIntro(onNext: () -> Unit) { var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { delay(INITIAL_DELAY.toLong()) visible = true } Box( modifier = Modifier .fillMaxSize() .background(Color.Black) ) { val infiniteTransition = rememberInfiniteTransition(label = "WrappedIntro bg") val scale by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 1.1f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 3000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "intro scale" ) val rotation by infiniteTransition.animateFloat( initialValue = -95f, targetValue = -85f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 5000, easing = LinearEasing), repeatMode = RepeatMode.Reverse ), label = "intro rotation" ) // Background "2025" text Box( modifier = Modifier .align(Alignment.CenterStart) .graphicsLayer { scaleX = scale scaleY = scale rotationZ = rotation } ) { BoxWithConstraints { AutoResizingText( text = stringResource(id = R.string.wrapped_year), style = TextStyle.Default.copy( fontFamily = bbhBartle, fontSize = 800.sp, // Increased size color = Color.White, drawStyle = Stroke(width = 2f) ), modifier = Modifier.width(this.maxHeight) // Use height for width due to rotation ) } } // Main Content Column Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { // App Icon AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(FADE_IN_DURATION, delayMillis = ICON_DELAY)) + slideInVertically(animationSpec = tween(SLIDE_IN_DURATION, delayMillis = ICON_DELAY)) ) { Icon( painter = painterResource(id = R.drawable.app_logo), contentDescription = stringResource(id = R.string.wrapped_logo_content_description), modifier = Modifier.size(100.dp) ) } Spacer(modifier = Modifier.height(16.dp)) // Metrolist Title with Layered Effect AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(FADE_IN_DURATION, delayMillis = TITLE_DELAY)) + slideInVertically(animationSpec = tween(SLIDE_IN_DURATION, delayMillis = TITLE_DELAY)) ) { Box { val baseStyle = TextStyle( fontFamily = bbhBartle, textAlign = TextAlign.Center, letterSpacing = 2.sp, fontSize = 50.sp ) AutoResizingText(text = stringResource(id = R.string.wrapped_intro_title), style = baseStyle.copy(color = Color.DarkGray), modifier = Modifier.offset(x = 2.dp, y = 2.dp)) AutoResizingText(text = stringResource(id = R.string.wrapped_intro_title), style = baseStyle.copy(color = Color.Gray), modifier = Modifier.offset(x = 1.dp, y = 1.dp)) AutoResizingText(text = stringResource(id = R.string.wrapped_intro_title), style = baseStyle.copy(color = Color.White)) } } Spacer(modifier = Modifier.height(8.dp)) // Subtitle AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(FADE_IN_DURATION, delayMillis = SUBTITLE_DELAY)) + slideInVertically(animationSpec = tween(SLIDE_IN_DURATION, delayMillis = SUBTITLE_DELAY)) ) { Text( text = stringResource(id = R.string.wrapped_intro_subtitle), color = Color.White, fontSize = 16.sp, textAlign = TextAlign.Center ) } } // "Let's go!" Button at the bottom AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(FADE_IN_DURATION, delayMillis = BUTTON_DELAY)) + slideInVertically(animationSpec = tween(SLIDE_IN_DURATION, delayMillis = BUTTON_DELAY)) { it }, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = BOTTOM_PADDING) ) { Button( onClick = onNext, shape = RoundedCornerShape(50), colors = ButtonDefaults.buttonColors(containerColor = Color.White) ) { Text( text = stringResource(id = R.string.wrapped_intro_button), color = Color.Black, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedMinutesScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween 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.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.material3.MaterialTheme import androidx.compose.material3.Text 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.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString 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.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.metrolist.music.ui.screens.wrapped.MessagePair import com.metrolist.music.ui.screens.wrapped.components.AnimatedDecorativeElement import com.metrolist.music.ui.theme.bbh_bartle import kotlin.random.Random @Composable fun WrappedMinutesScreen( messagePair: MessagePair?, totalMinutes: Long, isVisible: Boolean ) { val animatedMinutes = remember { Animatable(0f) } val textMeasurer = rememberTextMeasurer() LaunchedEffect(isVisible, totalMinutes) { if (isVisible && totalMinutes > 0) { animatedMinutes.animateTo(targetValue = totalMinutes.toFloat(), animationSpec = tween(1500, easing = FastOutSlowInEasing)) } } Box(modifier = Modifier.fillMaxSize()) { // More dynamic and overlapping decorative elements Box(modifier = Modifier.align(Alignment.TopStart)) { repeat(5) { AnimatedDecorativeElement( Modifier.padding(start = (Random.nextInt(0, 150)).dp, top = (Random.nextInt(0, 150)).dp).size((Random.nextInt(20, 100)).dp), isVisible ) } } Box(modifier = Modifier.align(Alignment.BottomEnd)) { repeat(5) { AnimatedDecorativeElement( Modifier.padding(end = (Random.nextInt(0, 150)).dp, bottom = (Random.nextInt(0, 150)).dp).size((Random.nextInt(20, 100)).dp), isVisible ) } } Box(modifier = Modifier.align(Alignment.TopEnd)) { repeat(3) { AnimatedDecorativeElement( Modifier.padding(end = (Random.nextInt(0, 100)).dp, top = (Random.nextInt(0, 100)).dp).size((Random.nextInt(20, 80)).dp), isVisible ) } } Box(modifier = Modifier.align(Alignment.BottomStart)) { repeat(3) { AnimatedDecorativeElement( Modifier.padding(start = (Random.nextInt(0, 100)).dp, bottom = (Random.nextInt(0, 100)).dp).size((Random.nextInt(20, 80)).dp), isVisible ) } } Column( modifier = Modifier.fillMaxSize().padding(vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { FormattedText( text = messagePair?.tease ?: "", modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.headlineSmall.copy(color = Color.White, textAlign = TextAlign.Center) ) Spacer(Modifier.height(32.dp)) BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { val density = LocalDensity.current val baseStyle = MaterialTheme.typography.displayLarge.copy( color = Color.White, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, fontFamily = bbh_bartle, drawStyle = Stroke(with(density) { 2.dp.toPx() }) ) val textStyle = remember(totalMinutes, maxWidth) { val finalText = totalMinutes.toString() var style = baseStyle.copy(fontSize = 96.sp) var textWidth = textMeasurer.measure(finalText, style).size.width while (textWidth > constraints.maxWidth) { style = style.copy(fontSize = style.fontSize * 0.95f) textWidth = textMeasurer.measure(finalText, style).size.width } style.copy(lineHeight = style.fontSize * 1.08f) } Text( text = animatedMinutes.value.toInt().toString(), style = textStyle, maxLines = 1, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) } Spacer(Modifier.height(16.dp)) FormattedText( text = messagePair?.reveal ?: "", modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.bodyLarge.copy(color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center) ) } } } @Composable fun FormattedText(text: String, modifier: Modifier = Modifier, style: androidx.compose.ui.text.TextStyle) { val annotatedString = buildAnnotatedString { val parts = text.split("(?=\\*\\*)|(?<=\\*\\*)".toRegex()) var isBold = false for (part in parts) { if (part == "**") isBold = !isBold else withStyle(SpanStyle(fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal)) { append(part) } } } Text(annotatedString, modifier, style = style) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedMinutesTease.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.scaleIn import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.metrolist.music.ui.screens.wrapped.LocalWrappedManager import com.metrolist.music.ui.screens.wrapped.MessagePair import com.metrolist.music.ui.theme.bbh_bartle import kotlinx.coroutines.delay @Composable fun WrappedMinutesTease( messagePair: MessagePair?, onNavigateForward: () -> Unit, isDataReady: Boolean ) { val manager = LocalWrappedManager.current LaunchedEffect(Unit) { delay(3500) onNavigateForward() } Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { AnimatedVisibility( visible = messagePair != null && isDataReady, enter = fadeIn(tween(1000)) + scaleIn(initialScale = 0.9f, animationSpec = tween(1000)) ) { Text( text = messagePair?.tease ?: "", modifier = Modifier.padding(horizontal = 24.dp), color = Color.White, fontSize = 30.sp, lineHeight = 34.sp, textAlign = TextAlign.Center, fontFamily = try { bbh_bartle } catch (e: Exception) { FontFamily.Default } ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTop5ArtistsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInVertically 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.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.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.graphics.Color import androidx.compose.ui.layout.ContentScale 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 coil3.compose.AsyncImage import com.metrolist.music.R import com.metrolist.music.db.entities.Artist import com.metrolist.music.ui.screens.wrapped.components.AnimatedBackground import com.metrolist.music.ui.screens.wrapped.components.ShapeType import com.metrolist.music.ui.theme.bbh_bartle import kotlinx.coroutines.delay @Composable fun WrappedTop5ArtistsScreen(topArtists: List, isVisible: Boolean) { var visible by remember { mutableStateOf(false) } LaunchedEffect(isVisible) { if (isVisible) { delay(200) visible = true } } Box(modifier = Modifier.fillMaxSize()) { AnimatedBackground(elementCount = 15, shapeTypes = listOf(ShapeType.Line)) Column( modifier = Modifier .fillMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 200)) + slideInVertically(animationSpec = tween(1000, delayMillis = 200)) ) { Text( text = stringResource(id = R.string.wrapped_top_5_artists_title), fontSize = 48.sp, color = Color.White, textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(32.dp)) Column { topArtists.forEachIndexed { index, artist -> AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(600, delayMillis = 400 + (index * 200))) + slideInVertically(animationSpec = tween(600, delayMillis = 400 + (index * 200))) ) { Row( modifier = Modifier .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = "${index + 1}", fontFamily = bbh_bartle, fontSize = 36.sp, color = Color.White.copy(alpha = 0.8f), modifier = Modifier.width(40.dp) ) Spacer(modifier = Modifier.width(16.dp)) AsyncImage( model = artist.artist.thumbnailUrl, contentDescription = "Artist image", modifier = Modifier .size(64.dp) .clip(CircleShape), contentScale = ContentScale.Crop ) Spacer(modifier = Modifier.width(16.dp)) Column { Text( text = artist.artist.name, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp ) Text( text = stringResource(id = R.string.wrapped_artist_listening_time, (artist.timeListened ?: 0) / 60000), color = Color.White.copy(alpha = 0.7f), fontSize = 14.sp ) } } } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTop5SongsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInVertically 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.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.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.graphics.Color import androidx.compose.ui.layout.ContentScale 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 coil3.compose.AsyncImage import com.metrolist.music.R import com.metrolist.music.db.entities.SongWithStats import com.metrolist.music.ui.screens.wrapped.components.AnimatedBackground import com.metrolist.music.ui.screens.wrapped.components.ShapeType import com.metrolist.music.ui.theme.bbh_bartle import kotlinx.coroutines.delay @Composable fun WrappedTop5SongsScreen(topSongs: List, isVisible: Boolean) { var visible by remember { mutableStateOf(false) } LaunchedEffect(isVisible) { if (isVisible) { delay(200) visible = true } } Box(modifier = Modifier.fillMaxSize()) { AnimatedBackground(elementCount = 25, shapeTypes = listOf(ShapeType.Rect)) Column( modifier = Modifier .fillMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 200)) + slideInVertically(animationSpec = tween(1000, delayMillis = 200)) ) { Text( text = stringResource(id = R.string.wrapped_top_5_songs_title), fontSize = 48.sp, color = Color.White, textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(32.dp)) Column { topSongs.forEachIndexed { index, song -> AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(600, delayMillis = 400 + (index * 200))) + slideInVertically(animationSpec = tween(600, delayMillis = 400 + (index * 200))) ) { Row( modifier = Modifier .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = "${index + 1}", fontFamily = bbh_bartle, fontSize = 36.sp, color = Color.White.copy(alpha = 0.8f), modifier = Modifier.width(40.dp) ) Spacer(modifier = Modifier.width(16.dp)) AsyncImage( model = song.thumbnailUrl, contentDescription = "Album art", modifier = Modifier .size(64.dp) .clip(RoundedCornerShape(3.dp)), contentScale = ContentScale.Crop ) Spacer(modifier = Modifier.width(16.dp)) Column { Text( text = song.title, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp ) Text( text = song.artists.joinToString(", ") { it.name }.ifBlank { song.artistName.orEmpty() }, color = Color.White.copy(alpha = 0.7f), fontSize = 14.sp ) } } } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTopArtistScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape 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.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext 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 coil3.compose.AsyncImage import coil3.request.ImageRequest import com.metrolist.music.R import com.metrolist.music.db.entities.Artist @Composable fun WrappedTopArtistScreen(topArtist: Artist?, isVisible: Boolean) { var visible by remember { mutableStateOf(false) } LaunchedEffect(isVisible) { if (isVisible) { visible = true } } Column( modifier = Modifier .fillMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 200)) + slideInVertically(animationSpec = tween(1000, delayMillis = 200)) ) { Text( text = stringResource(id = R.string.wrapped_top_artist_title), style = MaterialTheme.typography.headlineSmall, color = Color.White, textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(32.dp)) AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 400)) + slideInVertically(animationSpec = tween(1000, delayMillis = 400)) ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(topArtist?.artist?.thumbnailUrl) .build(), contentDescription = stringResource(id = R.string.wrapped_top_artist_image_content_description), modifier = Modifier .size(200.dp) .clip(CircleShape), contentScale = ContentScale.Crop ) } Spacer(modifier = Modifier.height(16.dp)) AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 600)) + slideInVertically(animationSpec = tween(1000, delayMillis = 600)) ) { Text( text = topArtist?.artist?.name ?: stringResource(id = R.string.wrapped_no_data), style = MaterialTheme.typography.headlineMedium, color = Color.White, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) } AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 800)) + slideInVertically(animationSpec = tween(1000, delayMillis = 800)) ) { Text( text = stringResource(id = R.string.wrapped_top_artist_listening_time, topArtist?.timeListened?.div(60000) ?: 0), style = MaterialTheme.typography.bodyLarge, color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTopSongScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInVertically import androidx.compose.foundation.layout.Arrangement 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.foundation.layout.size 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.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.layout.ContentScale import androidx.compose.ui.platform.LocalContext 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 coil3.compose.AsyncImage import coil3.request.ImageRequest import com.metrolist.music.R import com.metrolist.music.db.entities.SongWithStats import com.metrolist.music.ui.screens.wrapped.components.AnimatedDecorativeElement import kotlin.random.Random @Composable fun WrappedTopSongScreen(topSong: SongWithStats?, isVisible: Boolean) { var visible by remember { mutableStateOf(false) } LaunchedEffect(isVisible) { if (isVisible) { visible = true } } Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.align(Alignment.TopStart)) { repeat(3) { AnimatedDecorativeElement( Modifier.padding(start = (Random.nextInt(0, 100)).dp, top = (Random.nextInt(0, 100)).dp).size((Random.nextInt(20, 80)).dp), isVisible ) } } Box(modifier = Modifier.align(Alignment.BottomEnd)) { repeat(4) { AnimatedDecorativeElement( Modifier.padding(end = (Random.nextInt(0, 120)).dp, bottom = (Random.nextInt(0, 120)).dp).size((Random.nextInt(20, 90)).dp), isVisible ) } } Column( modifier = Modifier .fillMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 200)) + slideInVertically(animationSpec = tween(1000, delayMillis = 200)) ) { Text( text = stringResource(id = R.string.wrapped_top_song_title), style = MaterialTheme.typography.headlineSmall, color = Color.White, textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(32.dp)) AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 400)) + slideInVertically(animationSpec = tween(1000, delayMillis = 400)) ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(topSong?.thumbnailUrl) .build(), contentDescription = stringResource(id = R.string.wrapped_top_song_album_art_content_description), modifier = Modifier .size(200.dp) .clip(RoundedCornerShape(3.dp)), contentScale = ContentScale.Crop ) } Spacer(modifier = Modifier.height(16.dp)) AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 600)) + slideInVertically(animationSpec = tween(1000, delayMillis = 600)) ) { Text( text = topSong?.title ?: stringResource(id = R.string.wrapped_no_data), style = MaterialTheme.typography.headlineMedium, color = Color.White, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) } // Artists are not available in SongWithStats, so this part is removed. // A possible improvement would be to fetch artist data separately. Spacer(modifier = Modifier.height(8.dp)) AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(1000, delayMillis = 1000)) + slideInVertically(animationSpec = tween(1000, delayMillis = 1000)) ) { Text( text = stringResource(id = R.string.wrapped_top_song_listening_time, topSong?.timeListened?.div(60000) ?: 0), style = MaterialTheme.typography.bodyLarge, color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center ) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTotalArtistsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween 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.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.material3.MaterialTheme import androidx.compose.material3.Text 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.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.metrolist.music.R import com.metrolist.music.ui.screens.wrapped.components.AnimatedDecorativeElement import com.metrolist.music.ui.theme.bbh_bartle import kotlin.random.Random @Composable fun WrappedTotalArtistsScreen( uniqueArtistCount: Int, isVisible: Boolean ) { val animatedArtists = remember { Animatable(0f) } val textMeasurer = rememberTextMeasurer() LaunchedEffect(isVisible, uniqueArtistCount) { if (isVisible && uniqueArtistCount > 0) { animatedArtists.animateTo(targetValue = uniqueArtistCount.toFloat(), animationSpec = tween(1500, easing = FastOutSlowInEasing)) } } Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.align(Alignment.TopStart)) { repeat(5) { AnimatedDecorativeElement( Modifier.padding(start = (Random.nextInt(0, 150)).dp, top = (Random.nextInt(0, 150)).dp).size((Random.nextInt(20, 100)).dp), isVisible ) } } Box(modifier = Modifier.align(Alignment.BottomEnd)) { repeat(5) { AnimatedDecorativeElement( Modifier.padding(end = (Random.nextInt(0, 150)).dp, bottom = (Random.nextInt(0, 150)).dp).size((Random.nextInt(20, 100)).dp), isVisible ) } } Column( modifier = Modifier.fillMaxSize().padding(vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = stringResource(id = R.string.wrapped_total_artists_title), modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.headlineSmall.copy(color = Color.White, textAlign = TextAlign.Center) ) Spacer(Modifier.height(32.dp)) BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { val density = LocalDensity.current val baseStyle = MaterialTheme.typography.displayLarge.copy( color = Color.White, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, fontFamily = bbh_bartle, drawStyle = Stroke(with(density) { 2.dp.toPx() }) ) val textStyle = remember(uniqueArtistCount, maxWidth) { val finalText = uniqueArtistCount.toString() var style = baseStyle.copy(fontSize = 96.sp) var textWidth = textMeasurer.measure(finalText, style).size.width while (textWidth > constraints.maxWidth) { style = style.copy(fontSize = style.fontSize * 0.95f) textWidth = textMeasurer.measure(finalText, style).size.width } style.copy(lineHeight = style.fontSize * 1.08f) } Text( text = animatedArtists.value.toInt().toString(), style = textStyle, maxLines = 1, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) } Spacer(Modifier.height(16.dp)) Text( text = stringResource(id = R.string.wrapped_total_artists_subtitle), modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.bodyLarge.copy(color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/screens/wrapped/pages/WrappedTotalSongsScreen.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.screens.wrapped.pages import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween 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.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.material3.MaterialTheme import androidx.compose.material3.Text 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.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.metrolist.music.R import com.metrolist.music.ui.screens.wrapped.components.AnimatedBackground import com.metrolist.music.ui.screens.wrapped.components.ShapeType import com.metrolist.music.ui.theme.bbh_bartle @Composable fun WrappedTotalSongsScreen( uniqueSongCount: Int, isVisible: Boolean ) { val animatedSongs = remember { Animatable(0f) } val textMeasurer = rememberTextMeasurer() LaunchedEffect(isVisible, uniqueSongCount) { if (isVisible && uniqueSongCount > 0) { animatedSongs.animateTo(targetValue = uniqueSongCount.toFloat(), animationSpec = tween(1500, easing = FastOutSlowInEasing)) } } Box(modifier = Modifier.fillMaxSize()) { AnimatedBackground(shapeTypes = listOf(ShapeType.Line)) Column( modifier = Modifier.fillMaxSize().padding(vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Text( text = stringResource(id = R.string.wrapped_total_songs_title), modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.headlineSmall.copy(color = Color.White, textAlign = TextAlign.Center) ) Spacer(Modifier.height(32.dp)) BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { val density = LocalDensity.current val baseStyle = MaterialTheme.typography.displayLarge.copy( color = Color.White, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, fontFamily = bbh_bartle, drawStyle = Stroke(with(density) { 2.dp.toPx() }) ) val textStyle = remember(uniqueSongCount, maxWidth) { val finalText = uniqueSongCount.toString() var style = baseStyle.copy(fontSize = 96.sp) var textWidth = textMeasurer.measure(finalText, style).size.width while (textWidth > constraints.maxWidth) { style = style.copy(fontSize = style.fontSize * 0.95f) textWidth = textMeasurer.measure(finalText, style).size.width } style.copy(lineHeight = style.fontSize * 1.08f) } Text( text = animatedSongs.value.toInt().toString(), style = textStyle, maxLines = 1, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center ) } Spacer(Modifier.height(16.dp)) Text( text = stringResource(id = R.string.wrapped_total_songs_subtitle), modifier = Modifier.padding(horizontal = 24.dp), style = MaterialTheme.typography.bodyLarge.copy(color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center) ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/theme/Font.kt ================================================ package com.metrolist.music.ui.theme import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import com.metrolist.music.R val bbhBartle = FontFamily( Font(R.font.bbh_bartle_regular, FontWeight.Normal) ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/theme/PlayerColorExtractor.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.theme import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.palette.graphics.Palette import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext /** * Player color extraction system for generating gradients from album artwork * * This system analyzes album artwork and extracts vibrant, dominant colors * to create visually appealing gradients for the music player interface. */ object PlayerColorExtractor { /** * Extracts colors from a palette and creates a gradient * * @param palette The color palette extracted from album artwork * @param fallbackColor Fallback color to use if extraction fails * @return List of colors for gradient (primary, darker variant, black) */ suspend fun extractGradientColors( palette: Palette, fallbackColor: Int ): List = withContext(Dispatchers.Default) { // Extract all available colors with priority for dominant colors val colorCandidates = listOfNotNull( palette.dominantSwatch, // High priority for dominant color palette.vibrantSwatch, palette.darkVibrantSwatch, palette.lightVibrantSwatch, palette.mutedSwatch, palette.darkMutedSwatch, palette.lightMutedSwatch ) // Select best color based on weight (dominance + vibrancy) val bestSwatch = colorCandidates.maxByOrNull { calculateColorWeight(it) } val fallbackDominant = palette.dominantSwatch?.rgb?.let { Color(it) } ?: Color(palette.getDominantColor(fallbackColor)) val primaryColor = if (bestSwatch != null) { val bestColor = Color(bestSwatch.rgb) // Ensure the color is suitable for use if (isColorVibrant(bestColor)) { enhanceColorVividness(bestColor, 1.3f) } else { // If not vibrant, use dominant color with slight enhancement enhanceColorVividness(fallbackDominant, 1.1f) } } else { enhanceColorVividness(fallbackDominant, 1.1f) } // Create sophisticated gradient with 3 color points listOf( primaryColor, // Start: primary vibrant color primaryColor.copy( red = (primaryColor.red * 0.6f).coerceAtLeast(0f), green = (primaryColor.green * 0.6f).coerceAtLeast(0f), blue = (primaryColor.blue * 0.6f).coerceAtLeast(0f) ), // Middle: darker version of primary color Color.Black // End: black ) } /** * Determines if a color is vibrant enough for use in player UI * * @param color The color to analyze * @return true if the color has sufficient saturation and brightness */ private fun isColorVibrant(color: Color): Boolean { val argb = color.toArgb() val hsv = FloatArray(3) android.graphics.Color.colorToHSV(argb, hsv) val saturation = hsv[1] // HSV[1] is saturation val brightness = hsv[2] // HSV[2] is brightness // Color is vibrant if it has sufficient saturation and appropriate brightness // Avoid colors that are too dark or too bright return saturation > 0.25f && brightness > 0.2f && brightness < 0.9f } /** * Enhances color vividness by adjusting saturation and brightness * * @param color The color to enhance * @param saturationFactor Factor to multiply saturation by (default 1.4) * @return Enhanced color with improved vividness */ private fun enhanceColorVividness(color: Color, saturationFactor: Float = 1.4f): Color { val argb = color.toArgb() val hsv = FloatArray(3) android.graphics.Color.colorToHSV(argb, hsv) // Increase saturation for more vivid colors hsv[1] = (hsv[1] * saturationFactor).coerceAtMost(1.0f) // Adjust brightness for better visibility hsv[2] = (hsv[2] * 0.9f).coerceIn(0.4f, 0.85f) return Color(android.graphics.Color.HSVToColor(hsv)) } /** * Calculates weight for color selection based on dominance and vibrancy * * @param swatch The palette swatch to analyze * @return Weight value for color selection priority */ private fun calculateColorWeight(swatch: Palette.Swatch?): Float { if (swatch == null) return 0f val population = swatch.population.toFloat() val color = Color(swatch.rgb) val argb = color.toArgb() val hsv = FloatArray(3) android.graphics.Color.colorToHSV(argb, hsv) val saturation = hsv[1] val brightness = hsv[2] // Give higher priority to dominance (population) while considering vibrancy val populationWeight = population * 2f // Double dominance weight val vibrancyBonus = if (saturation > 0.3f && brightness > 0.3f) 1.5f else 1f return populationWeight * vibrancyBonus * (saturation + brightness) / 2f } /** * Configuration constants for color extraction */ object Config { const val MAX_COLOR_COUNT = 32 const val BITMAP_AREA = 8000 const val IMAGE_SIZE = 200 // Color enhancement factors const val VIBRANT_SATURATION_THRESHOLD = 0.25f const val VIBRANT_BRIGHTNESS_MIN = 0.2f const val VIBRANT_BRIGHTNESS_MAX = 0.9f const val POPULATION_WEIGHT_MULTIPLIER = 2f const val VIBRANCY_THRESHOLD_SATURATION = 0.3f const val VIBRANCY_THRESHOLD_BRIGHTNESS = 0.3f const val VIBRANCY_BONUS = 1.5f const val DEFAULT_SATURATION_FACTOR = 1.4f const val VIBRANT_SATURATION_FACTOR = 1.3f const val FALLBACK_SATURATION_FACTOR = 1.1f const val BRIGHTNESS_MULTIPLIER = 0.9f const val BRIGHTNESS_MIN = 0.4f const val BRIGHTNESS_MAX = 0.85f const val DARKER_VARIANT_FACTOR = 0.6f } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/theme/PlayerSliderColors.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.theme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SliderColors import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import com.metrolist.music.constants.PlayerBackgroundStyle /** * Player slider color configuration for consistent styling across all slider types * * This object provides standardized color schemes for Default, Squiggly, and Slim sliders * used in the music player interface, ensuring visual consistency and proper contrast. */ object PlayerSliderColors { /** * Standard slider colors for all slider types * * @param activeColor Color for active track, ticks, and thumb * @param playerBackground The player background style * @param useDarkTheme Whether dark theme is being used * @return SliderColors configuration */ @Composable fun getSliderColors( activeColor: Color, playerBackground: PlayerBackgroundStyle, useDarkTheme: Boolean ): SliderColors { val inactiveTrackColor = when (playerBackground) { PlayerBackgroundStyle.DEFAULT -> { if (useDarkTheme) { MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) } else { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) } } PlayerBackgroundStyle.BLUR, PlayerBackgroundStyle.GRADIENT -> { Color.White.copy(alpha = 0.4f) } } return SliderDefaults.colors( activeTrackColor = activeColor, activeTickColor = activeColor, thumbColor = activeColor, inactiveTrackColor = inactiveTrackColor, disabledActiveTrackColor = activeColor, disabledInactiveTrackColor = inactiveTrackColor, disabledThumbColor = activeColor ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/theme/Theme.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.theme import android.graphics.Bitmap import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme 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.materialkolor.PaletteStyle import com.materialkolor.dynamiccolor.ColorSpec import com.materialkolor.rememberDynamicColorScheme import com.materialkolor.score.Score val DefaultThemeColor = Color(0xFFED5564) @Composable fun MetrolistTheme( darkTheme: Boolean = isSystemInDarkTheme(), pureBlack: Boolean = false, themeColor: Color = DefaultThemeColor, content: @Composable () -> Unit, ) { val context = LocalContext.current // Determine if system dynamic colors should be used (Android S+ and default theme color) val useSystemDynamicColor = (themeColor == DefaultThemeColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) // Select the appropriate color scheme generation method val baseColorScheme = if (useSystemDynamicColor) { // Use standard Material 3 dynamic color functions for system wallpaper colors if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } else { // Use materialKolor only when a specific seed color is provided rememberDynamicColorScheme( seedColor = themeColor, // themeColor is guaranteed non-default here isDark = darkTheme, specVersion = ColorSpec.SpecVersion.SPEC_2025, style = PaletteStyle.TonalSpot // Keep existing style ) } // Apply pureBlack modification if needed, similar to original logic val colorScheme = remember(baseColorScheme, pureBlack, darkTheme) { if (darkTheme && pureBlack) { baseColorScheme.pureBlack(true) } else { baseColorScheme } } // Use standard MaterialTheme instead of MaterialExpressiveTheme MaterialTheme( colorScheme = colorScheme, typography = AppTypography, // Use the defined AppTypography 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)) } fun ColorScheme.pureBlack(apply: Boolean) = if (apply) copy( surface = Color.Black, background = 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() } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/theme/Type.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp // TODO: Define or import actual M3 Expressive font families if needed. // For now, using default FontFamily as a placeholder. // Define M3 Expressive Typography based on Material Design guidelines // https://m3.material.io/styles/typography/type-scale-tokens // Note: M3 Expressive might introduce subtle changes or new roles. // Referencing standard M3 roles for now, adjust if Expressive spec differs significantly. val AppTypography = Typography( displayLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 57.sp, lineHeight = 64.sp, letterSpacing = (-0.25).sp ), displayMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 45.sp, lineHeight = 52.sp, letterSpacing = 0.sp ), displaySmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 36.sp, lineHeight = 44.sp, letterSpacing = 0.sp ), headlineLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.sp ), headlineMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.sp ), headlineSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.sp ), titleLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, // M3 uses Normal, M2 used Medium fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp ), titleMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp ), titleSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp ), bodyLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp // M3 uses 0.5, M2 used 0.15 ), bodyMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp ), bodySmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp ), labelLarge = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp ), labelMedium = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp ), labelSmall = TextStyle( fontFamily = FontFamily.Default, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp ) ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/theme/bbh_bartle.kt ================================================ package com.metrolist.music.ui.theme import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import com.metrolist.music.R val bbh_bartle = FontFamily( Font(R.font.bbh_bartle_regular, FontWeight.Normal) ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/AppBar.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/FadingEdge.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/ItemWrapper.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/KeyUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.utils import java.util.concurrent.atomic.AtomicLong /** * Utility object for generating unique keys in LazyColumn/LazyRow to prevent duplicate key errors */ object KeyUtils { private val counter = AtomicLong(0) /** * Generates a unique key by combining a base identifier with a unique counter * This prevents duplicate keys in LazyColumn/LazyRow implementations */ fun generateUniqueKey(baseId: String, prefix: String = ""): String { val uniqueId = counter.incrementAndGet() return if (prefix.isNotEmpty()) { "${prefix}_${baseId}_$uniqueId" } else { "${baseId}_$uniqueId" } } /** * Generates a unique key for items in a list with their index * Useful for preventing duplicate keys when items might have the same ID */ fun generateIndexedKey(baseId: String, index: Int, prefix: String = ""): String { val uniqueId = counter.incrementAndGet() return if (prefix.isNotEmpty()) { "${prefix}_${baseId}_${index}_$uniqueId" } else { "${baseId}_${index}_$uniqueId" } } /** * Generates a timestamp-based unique key for dynamic content * Useful for content that changes frequently */ fun generateTimestampKey(baseId: String, prefix: String = ""): String { val timestamp = System.currentTimeMillis() val uniqueId = counter.incrementAndGet() return if (prefix.isNotEmpty()) { "${prefix}_${baseId}_${timestamp}_$uniqueId" } else { "${baseId}_${timestamp}_$uniqueId" } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 @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.forEach { 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 ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/NavControllerUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.utils import androidx.navigation.NavController import com.metrolist.music.ui.screens.Screens fun NavController.backToMain() { val mainRoutes = Screens.MainScreens.map { it.route } while (previousBackStackEntry != null && currentBackStackEntry?.destination?.route !in mainRoutes ) { popBackStack() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/ScrollUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @Composable fun LazyListState.isScrollingUp(): Boolean { var previousIndex by remember(this) { androidx.compose.runtime.mutableIntStateOf(firstVisibleItemIndex) } var previousScrollOffset by remember(this) { androidx.compose.runtime.mutableIntStateOf(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) { androidx.compose.runtime.mutableIntStateOf(firstVisibleItemIndex) } var previousScrollOffset by remember(this) { androidx.compose.runtime.mutableIntStateOf(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) { mutableIntStateOf(value) } return remember(this) { derivedStateOf { (previousScrollOffset >= value).also { previousScrollOffset = value } } }.value } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/ShapeUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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)) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/ShowMediaInfo.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.utils import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.text.format.Formatter import android.widget.Toast import androidx.compose.foundation.background 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.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.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState 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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.MediaInfo import com.metrolist.music.LocalDatabase import com.metrolist.music.LocalPlayerConnection import com.metrolist.music.R import com.metrolist.music.db.entities.FormatEntity import com.metrolist.music.db.entities.Song import com.metrolist.music.ui.component.Material3SettingsGroup import com.metrolist.music.ui.component.Material3SettingsItem import com.metrolist.music.ui.component.shimmer.ShimmerHost import com.metrolist.music.ui.component.shimmer.TextPlaceholder @Composable fun ShowMediaInfo(videoId: String) { if (videoId.isBlank() || videoId.isEmpty()) return val windowInsets = WindowInsets.systemBars var info by remember { mutableStateOf(null) } val database = LocalDatabase.current var song by remember { mutableStateOf(null) } var currentFormat by remember { mutableStateOf(null) } val playerConnection = LocalPlayerConnection.current val context = LocalContext.current LaunchedEffect(Unit, videoId) { info = YouTube.getMediaInfo(videoId).getOrNull() } LaunchedEffect(Unit, videoId) { database.song(videoId).collect { song = it } } LaunchedEffect(Unit, videoId) { database.format(videoId).collect { currentFormat = it } } LazyColumn( state = rememberLazyListState(), modifier = Modifier .padding( windowInsets .asPaddingValues() ) .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { if (info != null && song != null) { item(contentType = "MediaDetails") { Column { val baseList = listOf( stringResource(R.string.song_title) to song?.title, stringResource(R.string.song_artists) to song?.artists?.joinToString { it.name }, stringResource(R.string.media_id) to song?.id ) val baseIconsList = listOf( R.drawable.music_note, R.drawable.person, R.drawable.media3_icon_bookmark_filled, ) val iconsList = listOf( R.drawable.media3_icon_feed, R.drawable.media3_icon_thumb_up_unfilled, R.drawable.media3_icon_thumb_down_unfilled, R.drawable.key, R.drawable.info, R.drawable.radio, R.drawable.gradient, R.drawable.contrast, R.drawable.volume_up, R.drawable.volume_mute, R.drawable.content_copy ) val extendedList = if (currentFormat != null) { listOf( stringResource(R.string.views) to info?.viewCount?.let(::numberFormatter).orEmpty(), stringResource(R.string.likes) to info?.like?.let(::numberFormatter).orEmpty(), stringResource(R.string.dislikes) to info?.dislike?.let(::numberFormatter).orEmpty(), "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 if (playerConnection != null) "${(playerConnection.player.volume * 100).toInt()}%" else null, stringResource(R.string.file_size) to currentFormat?.contentLength?.let { Formatter.formatShortFileSize( context, it ) }, ) } else { emptyList() } val cardsBaseList = mutableListOf() val cardsExtendedList = mutableListOf() val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager baseList.forEachIndexed { index, (label, text) -> val displayText = text ?: stringResource(R.string.unknown) cardsBaseList += Material3SettingsItem( title = { Text(label) }, description = { Text(displayText) }, icon = painterResource(baseIconsList[index]), onClick = { cm.setPrimaryClip(ClipData.newPlainText("text", displayText)) Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() }, ) } extendedList.forEachIndexed { index, (label, text) -> val displayText = text ?: stringResource(R.string.unknown) cardsExtendedList += Material3SettingsItem( title = { Text(label) }, description = { Text(displayText) }, icon = painterResource(iconsList[index]), onClick = { cm.setPrimaryClip(ClipData.newPlainText("text", displayText)) Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() }, ) } Material3SettingsGroup( title = stringResource(R.string.general), items = cardsBaseList ) Spacer(Modifier.height(8.dp)) Material3SettingsGroup( title = stringResource(R.string.information), items = cardsExtendedList ) Spacer(Modifier.height(8.dp)) val descriptionText = info?.description ?: stringResource(R.string.unknown) Material3SettingsGroup( title = stringResource(R.string.description), items = listOf( Material3SettingsItem( title = { Text(stringResource(R.string.description)) }, description = { Text(descriptionText) }, onClick = { cm.setPrimaryClip(ClipData.newPlainText("text", descriptionText)) Toast.makeText(context, R.string.copied, Toast.LENGTH_SHORT).show() } ) ) ) } } } else { item(contentType = "MediaInfoLoader") { ShimmerHost { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(all = 16.dp) ) { TextPlaceholder() } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/ShowOffsetDialog.kt ================================================ package com.metrolist.music.ui.utils import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource 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.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.metrolist.music.LocalDatabase import com.metrolist.music.R import com.metrolist.music.db.entities.SongEntity import kotlinx.coroutines.FlowPreview @OptIn(FlowPreview::class) @Composable fun ShowOffsetDialog(songProvider: () -> SongEntity?) { val database = LocalDatabase.current val song = songProvider() var lyricsOffset by rememberSaveable { mutableIntStateOf(song?.lyricsOffset ?: 0) } var textFieldValue by rememberSaveable { mutableStateOf(lyricsOffset.toString()) } LaunchedEffect(song?.id) { song?.let { lyricsOffset = it.lyricsOffset textFieldValue = lyricsOffset.toString() } } LaunchedEffect(lyricsOffset) { songProvider()?.let { song -> database.query { upsert( song.copy( lyricsOffset = lyricsOffset ) ) } } } Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 20.dp) ) { Icon( painter = painterResource(R.drawable.fast_forward), contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(R.string.lyrics_offset), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(16.dp)) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth() ) { TextField( value = textFieldValue, onValueChange = { newText -> val sanitized = newText.filter { it.isDigit() || (it == '-' && newText.indexOf('-') == 0) } val limited = if (sanitized.startsWith('-')) { sanitized.take(6) } else { sanitized.take(5) } textFieldValue = limited when { limited.isEmpty() -> { lyricsOffset = 0 textFieldValue = "0" } limited == "-" -> { } else -> { limited.toIntOrNull()?.let { parsedValue -> val clampedValue = parsedValue.coerceIn(-9999, 9999) lyricsOffset = clampedValue if (parsedValue != clampedValue) { textFieldValue = clampedValue.toString() } if (clampedValue == 0 && limited.startsWith('-')) { textFieldValue = "0" } } } } }, singleLine = true, textStyle = MaterialTheme.typography.displaySmall.copy( textAlign = TextAlign.Center, fontWeight = FontWeight.Bold ), modifier = Modifier.widthIn(min = 120.dp, max = 160.dp), colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, cursorColor = MaterialTheme.colorScheme.primary, focusedIndicatorColor = MaterialTheme.colorScheme.primary, unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), disabledIndicatorColor = Color.Transparent, errorIndicatorColor = MaterialTheme.colorScheme.error ), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number, imeAction = ImeAction.Done ) ) Spacer(modifier = Modifier.width(8.dp)) Text( text = "ms", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium ) if (lyricsOffset != 0) { Spacer(Modifier.width(8.dp)) IconButton( onClick = { lyricsOffset = 0 textFieldValue = "0" } ) { Icon( painter = painterResource(R.drawable.replay), tint = MaterialTheme.colorScheme.primary, contentDescription = "Reset" ) } } } Spacer(modifier = Modifier.height(16.dp)) Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { IconButton( onClick = { lyricsOffset = (lyricsOffset - 50).coerceIn(-3000, 3000) textFieldValue = lyricsOffset.toString() } ) { Icon( painter = painterResource(R.drawable.remove), contentDescription = "Decrease" ) } Slider( value = lyricsOffset.toFloat(), onValueChange = { newValue -> val rounded = (newValue / 100).toInt() * 100 lyricsOffset = rounded textFieldValue = rounded.toString() }, valueRange = -3000f..3000f, steps = 59, modifier = Modifier.weight(1f) ) IconButton( onClick = { lyricsOffset = (lyricsOffset + 50).coerceIn(-3000, 3000) textFieldValue = lyricsOffset.toString() } ) { Icon( painter = painterResource(R.drawable.add), contentDescription = "Increase" ) } } Spacer(modifier = Modifier.height(8.dp)) Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() .padding(horizontal = 48.dp) ) { Text( text = "-3000ms", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( text = "+3000ms", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/StringUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.ui.utils import java.text.DecimalFormat 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" } fun numberFormatter(n: Int) = DecimalFormat("#,###") .format(n) .replace(",", ".") ================================================ FILE: app/src/main/kotlin/com/metrolist/music/ui/utils/YouTubeUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/CoilBitmapLoader.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import androidx.core.graphics.createBitmap import androidx.media3.common.util.BitmapLoader import coil3.imageLoader import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.request.allowHardware import coil3.toBitmap import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.guava.future import timber.log.Timber class CoilBitmapLoader( private val context: Context, private val scope: CoroutineScope, ) : BitmapLoader { override fun supportsMimeType(mimeType: String): Boolean = mimeType.startsWith("image/") private fun createFallbackBitmap(): Bitmap = createBitmap(64, 64) private fun Bitmap.copyIfNeeded(): Bitmap { return if (isRecycled) { createFallbackBitmap() } else { try { copy(Bitmap.Config.ARGB_8888, false) ?: createFallbackBitmap() } catch (e: Exception) { createFallbackBitmap() } } } override fun decodeBitmap(data: ByteArray): ListenableFuture = scope.future(Dispatchers.IO) { try { val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size) bitmap?.copyIfNeeded() ?: createFallbackBitmap() } catch (e: Exception) { Timber.tag("CoilBitmapLoader").w(e, "Failed to decode bitmap data") createFallbackBitmap() } } override fun loadBitmap(uri: Uri): ListenableFuture = scope.future(Dispatchers.IO) { val request = ImageRequest.Builder(context) .data(uri) .allowHardware(false) .build() val result = context.imageLoader.execute(request) when (result) { is ErrorResult -> { createFallbackBitmap() } is SuccessResult -> { try { val bitmap = result.image.toBitmap() bitmap.copyIfNeeded() } catch (e: Exception) { Timber.tag("CoilBitmapLoader").w(e, "Failed to convert image to bitmap") createFallbackBitmap() } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/ComposeDebugUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableLongStateOf 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 { mutableLongStateOf(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.longValue = 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.longValue 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, ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/ComposeToImage.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import android.content.ContentValues import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.LinearGradient import android.graphics.Paint import android.graphics.Path import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter 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.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.withClip import androidx.core.graphics.withTranslation import androidx.palette.graphics.Palette import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.toBitmap import com.metrolist.music.R import com.metrolist.music.ui.component.LyricsBackgroundStyle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import kotlin.math.abs import kotlin.math.roundToInt object ComposeToImage { suspend fun createLyricsImage( context: Context, coverArtUrl: String?, songTitle: String, artistName: String, lyrics: String, width: Int, height: Int, backgroundColor: Int? = null, backgroundStyle: LyricsBackgroundStyle = LyricsBackgroundStyle.SOLID, textColor: Int? = null, secondaryTextColor: Int? = null, lyricsAlignment: Layout.Alignment = Layout.Alignment.ALIGN_CENTER, ): Bitmap = withContext(Dispatchers.Default) { // Use fixed high resolution as requested (2160x2160) // This ensures consistent high-quality output regardless of the device screen val imageWidth = 2160 val imageHeight = 2160 val bitmap = createBitmap(imageWidth, imageHeight) val canvas = Canvas(bitmap) val defaultBackgroundColor = 0xFF121212.toInt() val defaultTextColor = 0xFFFFFFFF.toInt() val defaultSecondaryTextColor = 0xB3FFFFFF.toInt() val bgColor = backgroundColor ?: defaultBackgroundColor val mainTextColor = textColor ?: defaultTextColor val secondaryTxtColor = secondaryTextColor ?: defaultSecondaryTextColor // Pre-load cover art if needed for Blur/Gradient or just for the header var coverArtBitmap: Bitmap? = null if (coverArtUrl != null) { try { val imageLoader = ImageLoader(context) val request = ImageRequest .Builder(context) .data(coverArtUrl) .size(1024) .allowHardware(false) .build() val result = imageLoader.execute(request) coverArtBitmap = result.image?.toBitmap() } catch (_: Exception) { } } // Draw Background val backgroundRect = RectF(0f, 0f, imageWidth.toFloat(), imageHeight.toFloat()) val backgroundPaint = Paint().apply { isAntiAlias = true } when (backgroundStyle) { LyricsBackgroundStyle.SOLID -> { backgroundPaint.color = bgColor canvas.drawRect(backgroundRect, backgroundPaint) } LyricsBackgroundStyle.BLUR -> { // Draw black base backgroundPaint.color = 0xFF000000.toInt() canvas.drawRect(backgroundRect, backgroundPaint) if (coverArtBitmap != null) { try { // Create a scaled down version for blurring (performance) val scaledBitmap = Bitmap.createScaledBitmap(coverArtBitmap, imageWidth / 10, imageHeight / 10, true) val blurredBitmap = fastBlur(scaledBitmap, 1f, 20) // Radius 20 on small image is large blur if (blurredBitmap != null) { val blurRect = RectF(0f, 0f, imageWidth.toFloat(), imageHeight.toFloat()) canvas.drawBitmap(blurredBitmap, null, blurRect, null) // Dark overlay for readability val overlayPaint = Paint().apply { color = 0x4D000000 // 30% black overlay } canvas.drawRect(blurRect, overlayPaint) } } catch (e: Exception) { // Fallback to solid backgroundPaint.color = bgColor canvas.drawRect(backgroundRect, backgroundPaint) } } else { backgroundPaint.color = bgColor canvas.drawRect(backgroundRect, backgroundPaint) } } LyricsBackgroundStyle.GRADIENT -> { if (coverArtBitmap != null) { val palette = Palette.from(coverArtBitmap).generate() val vibrant = palette.getVibrantColor(bgColor) val darkVibrant = palette.getDarkVibrantColor(bgColor) val gradient = LinearGradient( 0f, 0f, imageWidth.toFloat(), imageHeight.toFloat(), intArrayOf(vibrant, darkVibrant), null, Shader.TileMode.CLAMP, ) backgroundPaint.shader = gradient canvas.drawRect(backgroundRect, backgroundPaint) } else { backgroundPaint.color = bgColor canvas.drawRect(backgroundRect, backgroundPaint) } } } // Base scale on width relative to the reference design (340dp) // 2160 / 340 ≈ 6.35 val scale = imageWidth / 340f val cornerRadius = 20f * scale // Draw inner border val borderPaint = Paint().apply { color = mainTextColor alpha = (255 * 0.09).toInt() style = Paint.Style.STROKE strokeWidth = 1f * scale isAntiAlias = true } canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, borderPaint) val padding = 28f * scale // --- Header Section --- val coverArtSize = 64f * scale val headerBottomPadding = 12f * scale val coverCornerRadius = 3f * scale coverArtBitmap?.let { val rect = RectF(padding, padding, padding + coverArtSize, padding + coverArtSize) val path = Path().apply { addRoundRect(rect, coverCornerRadius, coverCornerRadius, Path.Direction.CW) } // Draw border for cover art val coverBorderPaint = Paint().apply { color = mainTextColor alpha = (255 * 0.16).toInt() style = Paint.Style.STROKE strokeWidth = 1f * scale isAntiAlias = true } canvas.save() canvas.clipPath(path) canvas.drawBitmap(it, null, rect, null) canvas.restore() canvas.drawRoundRect(rect, coverCornerRadius, coverCornerRadius, coverBorderPaint) } val textStartX = padding + coverArtSize + (16f * scale) val textMaxWidth = imageWidth - textStartX - padding val titlePaint = TextPaint().apply { color = mainTextColor textSize = 20f * scale typeface = Typeface.DEFAULT_BOLD isAntiAlias = true } val artistPaint = TextPaint().apply { color = secondaryTxtColor textSize = 16f * scale typeface = Typeface.DEFAULT isAntiAlias = true } val titleLayout = StaticLayout.Builder .obtain(songTitle, 0, songTitle.length, titlePaint, textMaxWidth.toInt()) .setAlignment(Layout.Alignment.ALIGN_NORMAL) .setMaxLines(1) .setEllipsize(android.text.TextUtils.TruncateAt.END) .build() val artistLayout = StaticLayout.Builder .obtain(artistName, 0, artistName.length, artistPaint, textMaxWidth.toInt()) .setAlignment(Layout.Alignment.ALIGN_NORMAL) .setMaxLines(1) .setEllipsize(android.text.TextUtils.TruncateAt.END) .build() // Vertically align text block with cover art val headerTextHeight = titleLayout.height + artistLayout.height + (2f * scale) // +2dp padding between title and artist val headerCenterY = padding + coverArtSize / 2f val titleY = headerCenterY - headerTextHeight / 2f canvas.save() canvas.translate(textStartX, titleY) titleLayout.draw(canvas) canvas.translate(0f, titleLayout.height.toFloat() + (2f * scale)) artistLayout.draw(canvas) canvas.restore() // --- Footer Section --- val logoBoxSize = 22f * scale val logoIconSize = 16f * scale val footerY = imageHeight - padding - logoBoxSize // Draw Logo Background Box val logoBgPaint = Paint().apply { color = secondaryTxtColor isAntiAlias = true } val logoBoxRect = RectF(padding, footerY, padding + logoBoxSize, footerY + logoBoxSize) // Since it's a circle in preview: .clip(RoundedCornerShape(50)) which is usually circle for square box canvas.drawOval(logoBoxRect, logoBgPaint) // Draw Logo Icon val rawLogo = ContextCompat.getDrawable(context, R.drawable.small_icon)?.toBitmap() rawLogo?.let { val logoPaint = Paint().apply { // If background is gradient/blur, tint might be tricky. // Using bgColor for tint is safe for Solid, but for Gradient/Blur // we might want a color that contrasts with secondaryTxtColor. // Let's use the 'bgColor' passed in which is likely the dominant color or selected color. // Or for simplicity, use a generic dark/light depending on theme. colorFilter = PorterDuffColorFilter(bgColor, PorterDuff.Mode.SRC_IN) isAntiAlias = true } // Center logo in box val logoOffset = (logoBoxSize - logoIconSize) / 2f val logoRect = RectF( padding + logoOffset, footerY + logoOffset, padding + logoBoxSize - logoOffset, footerY + logoBoxSize - logoOffset, ) canvas.drawBitmap(it, null, logoRect, logoPaint) } // Draw App Name val appName = context.getString(R.string.app_name) val appNamePaint = TextPaint().apply { color = secondaryTxtColor textSize = 14f * scale typeface = Typeface.DEFAULT_BOLD isAntiAlias = true } val appNameX = padding + logoBoxSize + (8f * scale) // Center text vertically relative to logo box val appNameY = footerY + logoBoxSize / 2f - (appNamePaint.descent() + appNamePaint.ascent()) / 2f canvas.drawText(appName, appNameX, appNameY, appNamePaint) // --- Lyrics Section --- // Calculate available space val lyricsTop = padding + coverArtSize + headerBottomPadding val lyricsBottom = footerY - (12f * scale) // Add some padding above footer val lyricsHeight = lyricsBottom - lyricsTop val lyricsWidth = imageWidth - (padding * 2) val lyricsPaint = TextPaint().apply { color = mainTextColor typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) isAntiAlias = true letterSpacing = 0.005f } // Adaptive font size calculation // Start with a large size (e.g. 50sp equivalent) and scale down until it fits var lyricsTextSize = 50f * scale val minLyricsSize = 13f * scale var lyricsLayout: StaticLayout while (lyricsTextSize > minLyricsSize) { lyricsPaint.textSize = lyricsTextSize lyricsLayout = StaticLayout.Builder .obtain(lyrics, 0, lyrics.length, lyricsPaint, lyricsWidth.toInt()) .setAlignment(lyricsAlignment) .setLineSpacing(0f, 1.2f) .setIncludePad(false) .build() if (lyricsLayout.height <= lyricsHeight) { break } lyricsTextSize -= 1f * scale // Decrease by ~1sp equivalent steps } // One final rebuild with the determined size lyricsPaint.textSize = lyricsTextSize lyricsLayout = StaticLayout.Builder .obtain(lyrics, 0, lyrics.length, lyricsPaint, lyricsWidth.toInt()) .setAlignment(lyricsAlignment) .setLineSpacing(0f, 1.2f) .setIncludePad(false) .build() // Center vertically in the available space val lyricsContentHeight = lyricsLayout.height val lyricsY = if (lyricsContentHeight < lyricsHeight) { lyricsTop + (lyricsHeight - lyricsContentHeight) / 2f } else { lyricsTop } canvas.save() canvas.translate(padding, lyricsY) lyricsLayout.draw(canvas) canvas.restore() return@withContext bitmap } // Stack Blur v1.0 from http://www.quasimondo.com/StackBlurForCanvas/StackBlurDemo.html // Java Author: Mario Klingemann // http://incubator.quasimondo.com // // created Feburary 29, 2004 // Android port : Yahel Bouaziz // http://www.kayenko.com // ported to Kotlin and adapted private fun fastBlur( sentBitmap: Bitmap, scale: Float, radius: Int, ): Bitmap? { val width = (sentBitmap.width * scale).roundToInt() val height = (sentBitmap.height * scale).roundToInt() if (width <= 0 || height <= 0) return null val bitmap = Bitmap.createScaledBitmap(sentBitmap, width, height, false) val w = bitmap.width val h = bitmap.height val pix = IntArray(w * h) bitmap.getPixels(pix, 0, w, 0, 0, w, h) val wm = w - 1 val hm = h - 1 val wh = w * h val div = radius + radius + 1 val r = IntArray(wh) val g = IntArray(wh) val b = IntArray(wh) var rsum: Int var gsum: Int var bsum: Int var x: Int var y: Int var i: Int var p: Int var yp: Int var yi: Int var yw: Int val vmin = IntArray(Math.max(w, h)) var divsum = div + 1 shr 1 divsum *= divsum val dv = IntArray(256 * divsum) i = 0 while (i < 256 * divsum) { dv[i] = i / divsum i++ } yw = 0 yi = 0 val stack = Array(div) { IntArray(3) } var stackpointer: Int var stackstart: Int var sir: IntArray var rbs: Int var r1 = radius + 1 var routsum: Int var goutsum: Int var boutsum: Int var rinsum: Int var ginsum: Int var binsum: Int y = 0 while (y < h) { bsum = 0 gsum = 0 rsum = 0 boutsum = 0 goutsum = 0 routsum = 0 binsum = 0 ginsum = 0 rinsum = 0 i = -radius while (i <= radius) { p = pix[yi + Math.min(wm, Math.max(i, 0))] sir = stack[i + radius] sir[0] = p and 0xff0000 shr 16 sir[1] = p and 0x00ff00 shr 8 sir[2] = p and 0x0000ff rbs = r1 - Math.abs(i) rsum += sir[0] * rbs gsum += sir[1] * rbs bsum += sir[2] * rbs if (i > 0) { rinsum += sir[0] ginsum += sir[1] binsum += sir[2] } else { routsum += sir[0] goutsum += sir[1] boutsum += sir[2] } i++ } stackpointer = radius x = 0 while (x < w) { r[yi] = dv[rsum] g[yi] = dv[gsum] b[yi] = dv[bsum] rsum -= routsum gsum -= goutsum bsum -= boutsum stackstart = stackpointer - radius + div sir = stack[stackstart % div] routsum -= sir[0] goutsum -= sir[1] boutsum -= sir[2] if (y == 0) { vmin[x] = Math.min(x + radius + 1, wm) } p = pix[yw + vmin[x]] sir[0] = p and 0xff0000 shr 16 sir[1] = p and 0x00ff00 shr 8 sir[2] = p and 0x0000ff rinsum += sir[0] ginsum += sir[1] binsum += sir[2] rsum += rinsum gsum += ginsum bsum += binsum stackpointer = (stackpointer + 1) % div sir = stack[stackpointer % div] routsum += sir[0] goutsum += sir[1] boutsum += sir[2] rinsum -= sir[0] ginsum -= sir[1] binsum -= sir[2] yi++ x++ } yw += w y++ } x = 0 while (x < w) { bsum = 0 gsum = 0 rsum = 0 boutsum = 0 goutsum = 0 routsum = 0 binsum = 0 ginsum = 0 rinsum = 0 yp = -radius * w i = -radius while (i <= radius) { yi = Math.max(0, yp) + x sir = stack[i + radius] sir[0] = r[yi] sir[1] = g[yi] sir[2] = b[yi] rbs = r1 - Math.abs(i) rsum += sir[0] * rbs gsum += sir[1] * rbs bsum += sir[2] * rbs if (i > 0) { rinsum += sir[0] ginsum += sir[1] binsum += sir[2] } else { routsum += sir[0] goutsum += sir[1] boutsum += sir[2] } if (i < hm) { yp += w } i++ } yi = x stackpointer = radius y = 0 while (y < h) { pix[yi] = -0x1000000 or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum] rsum -= routsum gsum -= goutsum bsum -= boutsum stackstart = stackpointer - radius + div sir = stack[stackstart % div] routsum -= sir[0] goutsum -= sir[1] boutsum -= sir[2] if (x == 0) { vmin[y] = Math.min(y + r1, hm) * w } p = x + vmin[y] sir[0] = r[p] sir[1] = g[p] sir[2] = b[p] rinsum += sir[0] ginsum += sir[1] binsum += sir[2] rsum += rinsum gsum += ginsum bsum += binsum stackpointer = (stackpointer + 1) % div sir = stack[stackpointer % div] routsum += sir[0] goutsum += sir[1] boutsum += sir[2] rinsum -= sir[0] ginsum -= sir[1] binsum -= sir[2] yi += w y++ } x++ } bitmap.setPixels(pix, 0, w, 0, 0, w, h) return bitmap } fun saveBitmapAsFile( context: Context, bitmap: Bitmap, fileName: String, ): Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val 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 + "/Metrolist") } 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) } uri } else { 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) } FileProvider.getUriForFile( context, "${context.packageName}.FileProvider", imageFile, ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/CrashHandler.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import android.content.Context import android.content.Intent import android.os.Build import com.metrolist.music.BuildConfig import com.metrolist.music.ui.screens.CrashActivity import timber.log.Timber import java.io.PrintWriter import java.io.StringWriter import kotlin.system.exitProcess class CrashHandler private constructor( private val applicationContext: Context ) : Thread.UncaughtExceptionHandler { private val defaultHandler: Thread.UncaughtExceptionHandler? = Thread.getDefaultUncaughtExceptionHandler() override fun uncaughtException(thread: Thread, throwable: Throwable) { try { val crashLog = buildCrashLog(throwable) Timber.e(throwable, "App crashed") // Launch crash activity val intent = Intent(applicationContext, CrashActivity::class.java).apply { putExtra(EXTRA_CRASH_LOG, crashLog) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) } applicationContext.startActivity(intent) // Kill the current process android.os.Process.killProcess(android.os.Process.myPid()) exitProcess(1) } catch (e: Exception) { // If we fail to handle the crash, fall back to default handler Timber.e(e, "Error handling crash") defaultHandler?.uncaughtException(thread, throwable) } } private fun buildCrashLog(throwable: Throwable): String { val stackTrace = StringWriter().apply { throwable.printStackTrace(PrintWriter(this)) }.toString() return buildString { appendLine("Metrolist Crash Report") appendLine("=".repeat(50)) appendLine() appendLine("Manufacturer: ${Build.MANUFACTURER}") appendLine("Device: ${Build.MODEL}") appendLine("Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})") appendLine("App version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") appendLine() appendLine("=".repeat(50)) appendLine("Stacktrace:") appendLine("=".repeat(50)) appendLine() append(stackTrace) } } companion object { const val EXTRA_CRASH_LOG = "crash_log" fun install(context: Context) { val handler = CrashHandler(context.applicationContext) Thread.setDefaultUncaughtExceptionHandler(handler) Timber.d("CrashHandler installed") } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/DataStore.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.music.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 } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/DiscordRPC.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import android.content.Context import com.metrolist.music.R import com.metrolist.music.db.entities.Song import com.my.kizzy.rpc.KizzyRPC import com.my.kizzy.rpc.RpcImage class DiscordRPC( val context: Context, token: String, ) : KizzyRPC( token = token, os = "Android", browser = "Discord Android", device = android.os.Build.DEVICE, userAgent = SuperProperties.userAgent, superPropertiesBase64 = SuperProperties.superPropertiesBase64 ) { suspend fun updateSong( song: Song, currentPlaybackTimeMillis: Long, playbackSpeed: Float = 1.0f, useDetails: Boolean = false, status: String = "online", button1Text: String = "", button1Visible: Boolean = true, button2Text: String = "", button2Visible: Boolean = true, activityType: String = "listening", activityName: String = "", ) = 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() val buttonsList = mutableListOf>() if (button1Visible) { val resolvedText = resolveVariables( button1Text.ifEmpty { "Listen on YouTube Music" }, song ) buttonsList.add(resolvedText to "https://music.youtube.com/watch?v=${song.song.id}") } if (button2Visible) { val resolvedText = resolveVariables( button2Text.ifEmpty { "Visit Metrolist" }, song ) buttonsList.add(resolvedText to "https://github.com/MetrolistGroup/Metrolist") } val type = when (activityType) { "playing" -> Type.PLAYING "watching" -> Type.WATCHING "competing" -> Type.COMPETING else -> Type.LISTENING } val name = activityName.ifEmpty { context.getString(R.string.app_name).removeSuffix(" Debug") } setActivity( name = name, 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 = if (buttonsList.isNotEmpty()) buttonsList else null, type = type, statusDisplayType = if (useDetails) StatusDisplayType.DETAILS else StatusDisplayType.STATE, since = currentTime, startTime = calculatedStartTime, endTime = currentTime + adjustedRemainingDuration, applicationId = APPLICATION_ID, status = status ) } override suspend fun close() { super.close() } companion object { private const val APPLICATION_ID = "1411019391843172514" /** * Resolves template variables in text. * Supported: {song_name}, {artist_name}, {album_name} */ fun resolveVariables(text: String, song: Song): String { return text .replace("{song_name}", song.song.title) .replace("{artist_name}", song.artists.joinToString { it.name }) .replace("{album_name}", song.album?.title ?: "") } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/IconUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager object IconUtils { fun setIcon(context: Context, enabled: Boolean) { val pm = context.packageManager val dynamic = ComponentName(context, "com.metrolist.music.MainActivityAlias") val static = ComponentName(context, "com.metrolist.music.MainActivityStatic") pm.setComponentEnabledSetting( dynamic, if (enabled) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP ) pm.setComponentEnabledSetting( static, if (enabled) PackageManager.COMPONENT_ENABLED_STATE_DISABLED else PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/NetworkConnectivityObserver.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.Channel import kotlinx.coroutines.flow.receiveAsFlow /** * Simple NetworkConnectivityObserver based on OuterTune's implementation * Provides network connectivity monitoring for auto-play functionality */ class NetworkConnectivityObserver(context: Context) { private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager private val _networkStatus = Channel(Channel.CONFLATED) val networkStatus = _networkStatus.receiveAsFlow() private val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { _networkStatus.trySend(true) } override fun onLost(network: Network) { _networkStatus.trySend(false) } } init { val request = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) .build() try { connectivityManager.registerNetworkCallback(request, networkCallback) } catch (e: Exception) { // Fallback: assume connected if registration fails _networkStatus.trySend(true) } // Send initial state val isInitiallyConnected = isCurrentlyConnected() _networkStatus.trySend(isInitiallyConnected) } fun unregister() { connectivityManager.unregisterNetworkCallback(networkCallback) } /** * Check current connectivity state synchronously */ fun isCurrentlyConnected(): Boolean { return try { val activeNetwork = connectivityManager.activeNetwork val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) // Check if we have internet capability val hasInternet = networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true // For API 23+, also check if connection is validated val isValidated = networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true hasInternet && isValidated } catch (e: Exception) { false } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/NetworkUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/PlaylistExporter.kt ================================================ package com.metrolist.music.utils import android.content.ContentValues import android.content.Context import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore import androidx.core.content.FileProvider import com.metrolist.innertube.models.SongItem import com.metrolist.music.db.entities.PlaylistSong import java.io.File import java.io.FileWriter import java.io.IOException object PlaylistExporter { fun exportPlaylistAsCSV( context: Context, playlistName: String, songs: List, ): Result = try { val csvContent = buildString { // Add CSV header append("Title,Artist,Album,YouTube Video ID\n") // Add each song as a CSV row songs.forEach { playlistSong -> val song = playlistSong.song.song val artists = playlistSong.song.artists val album = playlistSong.song.album append("\"${song.title.replace("\"", "\"\"")}\"") append(",") append("\"${artists.joinToString("; ") { it.name.replace("\"", "\"\"") }}\"") append(",") append("\"${album?.title?.replace("\"", "\"\"") ?: ""}\"") append(",") append("${song.id}") append("\n") } } // Save to file val file = createExportFile(context, "$playlistName.csv") FileWriter(file).use { it.write(csvContent) } Result.success(file) } catch (e: IOException) { Result.failure(e) } fun exportPlaylistAsM3U( context: Context, playlistName: String, songs: List, ): Result = try { val m3uContent = buildString { // Add M3U header append("#EXTM3U\n") // Add each song as M3U entry songs.forEach { playlistSong -> val song = playlistSong.song.song val artists = playlistSong.song.artists append("#EXTINF:${song.duration},") append("${artists.joinToString(";") { it.name }} - ${song.title}") append("\n") append("https://youtube.com/watch?v=${song.id}\n") } } // Save to file val file = createExportFile(context, "$playlistName.m3u") FileWriter(file).use { it.write(m3uContent) } Result.success(file) } catch (e: IOException) { Result.failure(e) } } fun exportYouTubePlaylistAsCSV( context: Context, playlistName: String, songs: List, ): Result = try { val csvContent = buildString { // Add CSV header append("Title,Artist,Album,YouTube Video ID\n") // Add each song as a CSV row songs.forEach { songItem -> append("\"${songItem.title.replace("\"", "\"\"")}\"") append(",") append("\"${songItem.artists.joinToString("; ") { it.name.replace("\"", "\"\"") }}\"") append(",") append("\"${songItem.album?.name?.replace("\"", "\"\"") ?: ""}\"") append(",") append("${songItem.id}") append("\n") } } // Save to file val file = createExportFile(context, "$playlistName.csv") FileWriter(file).use { it.write(csvContent) } Result.success(file) } catch (e: IOException) { Result.failure(e) } fun exportYouTubePlaylistAsM3U( context: Context, playlistName: String, songs: List, ): Result = try { val m3uContent = buildString { // Add M3U header append("#EXTM3U\n") // Add each song as M3U entry songs.forEach { songItem -> append("#EXTINF:${songItem.duration},") append("${songItem.artists.joinToString(" - ") { it.name }} - ${songItem.title}") append("\n") // For M3U, we would typically include a URL, but since we don't have direct URLs, // we'll use a placeholder that indicates this is a YouTube Music track append("#YTM:${songItem.id}\n") } } // Save to file val file = createExportFile(context, "$playlistName.m3u") FileWriter(file).use { it.write(m3uContent) } Result.success(file) } catch (e: IOException) { Result.failure(e) } private fun createExportFile( context: Context, filename: String, ): File { // Create directory if it doesn't exist val exportDir = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "MetrolistExports") if (!exportDir.exists()) { exportDir.mkdirs() } // Create file with unique name (add timestamp if file exists) val baseFilename = filename.substringBeforeLast('.') val extension = filename.substringAfterLast('.', "") var exportFile = File(exportDir, filename) var counter = 1 while (exportFile.exists()) { val newFilename = if (extension.isNotEmpty()) { "${baseFilename}_$counter.$extension" } else { "${baseFilename}_$counter" } exportFile = File(exportDir, newFilename) counter++ } exportFile.createNewFile() return exportFile } private fun getFileUri( context: Context, file: File, ): Uri = FileProvider.getUriForFile( context, "${context.packageName}.FileProvider", file, ) fun getExportFileUri( context: Context, file: File, ): Uri = getFileUri(context, file) /** * Copy a generated export file into the public Documents/MetrolistExports folder using MediaStore (scoped storage). * Returns the Uri to the public copy on success. */ fun saveToPublicDocuments( context: Context, source: File, mimeType: String, subdirectory: String = "MetrolistExports", ): Result { return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val resolver = context.contentResolver val relativePath = Environment.DIRECTORY_DOCUMENTS + "/" + subdirectory val values = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, source.name) put(MediaStore.MediaColumns.MIME_TYPE, mimeType) put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) put(MediaStore.MediaColumns.IS_PENDING, 1) } // Use the primary external volume for generic files val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val destUri = resolver.insert(collection, values) ?: return Result.failure(IOException("Failed to create destination in MediaStore")) resolver.openOutputStream(destUri)?.use { out -> source.inputStream().use { input -> input.copyTo(out) } } ?: return Result.failure(IOException("Failed to open output stream for MediaStore uri")) // Mark as not pending so it becomes visible val complete = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) } resolver.update(destUri, complete, null, null) Result.success(destUri) } else { // Best-effort fallback: keep the file in app-scoped Documents and return a sharable uri // Older Android versions would require WRITE_EXTERNAL_STORAGE for true public Documents Result.success(getExportFileUri(context, source)) } } catch (e: Exception) { Result.failure(e) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/PodcastRefreshTrigger.kt ================================================ package com.metrolist.music.utils import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow /** * Simple singleton to trigger podcast library refresh from anywhere. * Used when subscribing/unsubscribing from channels. */ object PodcastRefreshTrigger { private val _refreshFlow = MutableSharedFlow(extraBufferCapacity = 1) val refreshFlow = _refreshFlow.asSharedFlow() fun triggerRefresh() { _refreshFlow.tryEmit(Unit) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/ScrobbleManager.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import com.metrolist.lastfm.LastFM import com.metrolist.music.models.MediaMetadata import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.min class ScrobbleManager( private val scope: CoroutineScope, var minSongDuration: Int = 30, var scrobbleDelayPercent: Float = 0.5f, var scrobbleDelaySeconds: Int = 50 ) { private var scrobbleJob: Job? = null private var scrobbleRemainingMillis: Long = 0L private var scrobbleTimerStartedAt: Long = 0L private var songStartedAt: Long = 0L private var songStarted = false var useNowPlaying = true fun destroy() { scrobbleJob?.cancel() scrobbleRemainingMillis = 0L scrobbleTimerStartedAt = 0L songStartedAt = 0L songStarted = false } fun onSongStart(metadata: MediaMetadata?, duration: Long? = null) { if (metadata == null) return songStartedAt = System.currentTimeMillis() / 1000 songStarted = true startScrobbleTimer(metadata, duration) if (useNowPlaying) { updateNowPlaying(metadata) } } fun onSongResume(metadata: MediaMetadata) { resumeScrobbleTimer(metadata) } fun onSongPause() { pauseScrobbleTimer() } fun onSongStop() { stopScrobbleTimer() songStarted = false } private fun startScrobbleTimer(metadata: MediaMetadata, duration: Long? = null) { scrobbleJob?.cancel() val duration = duration?.toInt()?.div(1000) ?: metadata.duration if (duration <= minSongDuration) return val threshold = duration * 1000L * scrobbleDelayPercent scrobbleRemainingMillis = min(threshold.toLong(), scrobbleDelaySeconds * 1000L) if (scrobbleRemainingMillis <= 0) { scrobbleSong(metadata) return } scrobbleTimerStartedAt = System.currentTimeMillis() scrobbleJob = scope.launch { delay(scrobbleRemainingMillis) scrobbleSong(metadata) scrobbleJob = null } } private fun pauseScrobbleTimer() { scrobbleJob?.cancel() if (scrobbleTimerStartedAt != 0L) { val elapsed = System.currentTimeMillis() - scrobbleTimerStartedAt scrobbleRemainingMillis -= elapsed if (scrobbleRemainingMillis < 0) scrobbleRemainingMillis = 0 scrobbleTimerStartedAt = 0L } else { } } private fun resumeScrobbleTimer(metadata: MediaMetadata) { if (scrobbleRemainingMillis <= 0) return scrobbleJob?.cancel() scrobbleTimerStartedAt = System.currentTimeMillis() scrobbleJob = scope.launch { delay(scrobbleRemainingMillis) scrobbleSong(metadata) scrobbleJob = null } } private fun stopScrobbleTimer() { scrobbleJob?.cancel() scrobbleJob = null scrobbleRemainingMillis = 0 } private fun scrobbleSong(metadata: MediaMetadata) { scope.launch { LastFM.scrobble( artist = metadata.artists.joinToString { it.name }, track = metadata.title, duration = metadata.duration, timestamp = songStartedAt, album = metadata.album?.title, ) } } private fun updateNowPlaying(metadata: MediaMetadata) { scope.launch { LastFM.updateNowPlaying( artist = metadata.artists.joinToString { it.name }, track = metadata.title, album = metadata.album?.title, duration = metadata.duration ) } } fun onPlayerStateChanged(isPlaying: Boolean, metadata: MediaMetadata?, duration: Long? = null) { if (metadata == null) return if (isPlaying) { if (!songStarted) { onSongStart(metadata, duration) } else { onSongResume(metadata) } } else { onSongPause() } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/StringUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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 = " • ") ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/SuperProperties.kt ================================================ package com.metrolist.music.utils import android.os.Build import android.util.Base64 import org.json.JSONObject import java.util.Locale import java.util.UUID object SuperProperties { // Constants from research for Discord Android 314.13 private const val CLIENT_VERSION = "314.13 - Stable" private const val CLIENT_BUILD_NUMBER = 314013 private const val RELEASE_CHANNEL = "googleRelease" // Lazy loaded properties to avoid re-generating UUIDs val superProperties: JSONObject by lazy { JSONObject().apply { put("os", "Android") put("browser", "Discord Android") put("device", Build.DEVICE) put("system_locale", Locale.getDefault().toString()) put("client_version", CLIENT_VERSION) put("release_channel", RELEASE_CHANNEL) put("device_vendor_id", UUID.randomUUID().toString()) put("client_uuid", UUID.randomUUID().toString()) put("client_launch_id", UUID.randomUUID().toString()) put("os_version", Build.VERSION.RELEASE) put("os_sdk_version", Build.VERSION.SDK_INT.toString()) put("client_build_number", CLIENT_BUILD_NUMBER) put("client_event_source", JSONObject.NULL) put("design_id", 0) } } val superPropertiesBase64: String by lazy { val jsonString = superProperties.toString() Base64.encodeToString(jsonString.toByteArray(), Base64.NO_WRAP) } val userAgent: String by lazy { "Discord-Android/$CLIENT_BUILD_NUMBER;RNA" } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/SyncUtils.kt ================================================ /** * Metrolist Project (C) 2026 * OuterTune Project Copyright (C) 2025 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import android.content.Context import androidx.datastore.preferences.core.edit import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.utils.completed import com.metrolist.innertube.utils.parseCookieString import com.metrolist.lastfm.LastFM import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.LastFMUseSendLikes import com.metrolist.music.constants.LastFullSyncKey import com.metrolist.music.constants.SYNC_COOLDOWN import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.ArtistEntity import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.PlaylistSongMap import com.metrolist.music.db.entities.PodcastEntity import com.metrolist.music.db.entities.SetVideoIdEntity import com.metrolist.music.db.entities.SongEntity import com.metrolist.music.extensions.collectLatest import com.metrolist.music.extensions.isInternetConnected import com.metrolist.music.extensions.isSyncEnabled import com.metrolist.music.models.toMediaMetadata import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber import java.time.LocalDateTime import java.time.ZoneOffset import javax.inject.Inject import javax.inject.Singleton sealed class SyncOperation { data object FullSync : SyncOperation() data object LikedSongs : SyncOperation() data object LibrarySongs : SyncOperation() data object UploadedSongs : SyncOperation() data object LikedAlbums : SyncOperation() data object UploadedAlbums : SyncOperation() data object ArtistsSubscriptions : SyncOperation() data object PodcastSubscriptions : SyncOperation() data object EpisodesForLater : SyncOperation() data object SavedPlaylists : SyncOperation() data object AutoSyncPlaylists : SyncOperation() data class SinglePlaylist(val browseId: String, val playlistId: String) : SyncOperation() data class LikeSong(val song: SongEntity) : SyncOperation() data class SubscribeChannel(val channelId: String, val subscribe: Boolean) : SyncOperation() data class SavePodcast(val podcastId: String, val save: Boolean) : SyncOperation() data class SaveEpisode(val episodeId: String, val save: Boolean, val setVideoId: String?) : SyncOperation() data object CleanupDuplicates : SyncOperation() data object ClearAllSynced : SyncOperation() data object ClearPodcastData : SyncOperation() } sealed class SyncStatus { data object Idle : SyncStatus() data object Syncing : SyncStatus() data class Error(val message: String) : SyncStatus() data object Completed : SyncStatus() } data class SyncState( val overallStatus: SyncStatus = SyncStatus.Idle, val likedSongs: SyncStatus = SyncStatus.Idle, val librarySongs: SyncStatus = SyncStatus.Idle, val uploadedSongs: SyncStatus = SyncStatus.Idle, val likedAlbums: SyncStatus = SyncStatus.Idle, val uploadedAlbums: SyncStatus = SyncStatus.Idle, val artists: SyncStatus = SyncStatus.Idle, val playlists: SyncStatus = SyncStatus.Idle, val currentOperation: String = "" ) @Singleton class SyncUtils @Inject constructor( @ApplicationContext private val context: Context, private val database: MusicDatabase, ) { private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> if (throwable !is CancellationException) { Timber.e(throwable, "Sync coroutine exception") } } private val syncJob = SupervisorJob() private val syncScope = CoroutineScope(Dispatchers.IO + syncJob + exceptionHandler) private val syncChannel = Channel(Channel.BUFFERED) private var processingJob: Job? = null private val _syncState = MutableStateFlow(SyncState()) val syncState: StateFlow = _syncState.asStateFlow() private var lastfmSendLikes = false companion object { private const val MAX_RETRIES = 3 private const val INITIAL_RETRY_DELAY_MS = 1000L private const val DB_OPERATION_DELAY_MS = 50L } init { context.dataStore.data .map { it[LastFMUseSendLikes] ?: false } .distinctUntilChanged() .collectLatest(syncScope) { lastfmSendLikes = it } startProcessingQueue() } private fun startProcessingQueue() { processingJob = syncScope.launch { for (operation in syncChannel) { try { processOperation(operation) } catch (e: CancellationException) { throw e } catch (e: Exception) { Timber.e(e, "Error processing sync operation: $operation") } } } } private suspend fun processOperation(operation: SyncOperation) { when (operation) { is SyncOperation.FullSync -> executeFullSync() is SyncOperation.LikedSongs -> executeSyncLikedSongs() is SyncOperation.LibrarySongs -> executeSyncLibrarySongs() is SyncOperation.UploadedSongs -> executeSyncUploadedSongs() is SyncOperation.LikedAlbums -> executeSyncLikedAlbums() is SyncOperation.UploadedAlbums -> executeSyncUploadedAlbums() is SyncOperation.ArtistsSubscriptions -> executeSyncArtistsSubscriptions() is SyncOperation.PodcastSubscriptions -> executeSyncPodcastSubscriptions() is SyncOperation.EpisodesForLater -> executeSyncEpisodesForLater() is SyncOperation.SavedPlaylists -> executeSyncSavedPlaylists() is SyncOperation.AutoSyncPlaylists -> executeSyncAutoSyncPlaylists() is SyncOperation.SinglePlaylist -> executeSyncPlaylist(operation.browseId, operation.playlistId) is SyncOperation.LikeSong -> executeLikeSong(operation.song) is SyncOperation.SubscribeChannel -> executeSubscribeChannel(operation.channelId, operation.subscribe) is SyncOperation.SavePodcast -> executeSavePodcast(operation.podcastId, operation.save) is SyncOperation.SaveEpisode -> executeSaveEpisode(operation.episodeId, operation.save, operation.setVideoId) is SyncOperation.CleanupDuplicates -> executeCleanupDuplicatePlaylists() is SyncOperation.ClearAllSynced -> executeClearAllSyncedContent() is SyncOperation.ClearPodcastData -> executeClearPodcastData() } } private suspend fun isLoggedIn(): Boolean { return try { val cookie = context.dataStore.data .map { it[InnerTubeCookieKey] } .first() cookie?.let { "SAPISID" in parseCookieString(it) } ?: false } catch (e: Exception) { Timber.e(e, "Error checking login status") false } } private suspend fun withRetry( maxRetries: Int = MAX_RETRIES, initialDelay: Long = INITIAL_RETRY_DELAY_MS, block: suspend () -> T ): Result { var currentDelay = initialDelay repeat(maxRetries) { attempt -> try { return Result.success(block()) } catch (e: CancellationException) { throw e } catch (e: Exception) { Timber.w(e, "Attempt ${attempt + 1}/$maxRetries failed") if (attempt == maxRetries - 1) { return Result.failure(e) } delay(currentDelay) currentDelay *= 2 } } return Result.failure(Exception("Max retries exceeded")) } private fun updateState(update: SyncState.() -> SyncState) { _syncState.value = _syncState.value.update() } // Public API methods - Queue operations fun performFullSync() { syncScope.launch { syncChannel.send(SyncOperation.FullSync) } } suspend fun performFullSyncSuspend() { if (!isLoggedIn()) { Timber.w("Skipping full sync - user not logged in") return } executeFullSync() } fun tryAutoSync() { syncScope.launch { if (!isLoggedIn()) { Timber.d("Skipping auto sync - user not logged in") return@launch } if (!context.isSyncEnabled() || !context.isInternetConnected()) { return@launch } val lastSync = context.dataStore.get(LastFullSyncKey, 0L) val currentTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) if (lastSync > 0 && (currentTime - lastSync) < SYNC_COOLDOWN) { return@launch } syncChannel.send(SyncOperation.FullSync) context.dataStore.edit { settings -> settings[LastFullSyncKey] = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) } } } fun runAllSyncs() { performFullSync() } fun likeSong(s: SongEntity) { syncScope.launch { syncChannel.send(SyncOperation.LikeSong(s)) } } fun subscribeChannel(channelId: String, subscribe: Boolean) { syncScope.launch { syncChannel.send(SyncOperation.SubscribeChannel(channelId, subscribe)) } } fun savePodcast(podcastId: String, save: Boolean) { Timber.d("[PODCAST_TOGGLE] SyncUtils.savePodcast called: podcastId=$podcastId, save=$save") syncScope.launch { Timber.d("[PODCAST_TOGGLE] Sending SavePodcast operation to channel") syncChannel.send(SyncOperation.SavePodcast(podcastId, save)) } } fun saveEpisode(episodeId: String, save: Boolean, setVideoId: String? = null) { syncScope.launch { syncChannel.send(SyncOperation.SaveEpisode(episodeId, save, setVideoId)) } } fun syncLikedSongs() { syncScope.launch { syncChannel.send(SyncOperation.LikedSongs) } } fun syncLibrarySongs() { syncScope.launch { syncChannel.send(SyncOperation.LibrarySongs) } } fun syncUploadedSongs() { syncScope.launch { syncChannel.send(SyncOperation.UploadedSongs) } } fun syncLikedAlbums() { syncScope.launch { syncChannel.send(SyncOperation.LikedAlbums) } } fun syncUploadedAlbums() { syncScope.launch { syncChannel.send(SyncOperation.UploadedAlbums) } } fun syncArtistsSubscriptions() { syncScope.launch { syncChannel.send(SyncOperation.ArtistsSubscriptions) } } fun syncSavedPlaylists() { syncScope.launch { syncChannel.send(SyncOperation.SavedPlaylists) } } fun syncAutoSyncPlaylists() { syncScope.launch { syncChannel.send(SyncOperation.AutoSyncPlaylists) } } fun syncAllAlbums() { syncScope.launch { syncChannel.send(SyncOperation.LikedAlbums) syncChannel.send(SyncOperation.UploadedAlbums) } } fun syncAllArtists() { syncScope.launch { syncChannel.send(SyncOperation.ArtistsSubscriptions) } } fun syncPodcastSubscriptions() { syncScope.launch { syncChannel.send(SyncOperation.PodcastSubscriptions) } } fun syncEpisodesForLater() { syncScope.launch { syncChannel.send(SyncOperation.EpisodesForLater) } } fun cleanupDuplicatePlaylists() { syncScope.launch { syncChannel.send(SyncOperation.CleanupDuplicates) } } fun clearAllSyncedContent() { syncScope.launch { syncChannel.send(SyncOperation.ClearAllSynced) } } fun clearPodcastData() { syncScope.launch { syncChannel.send(SyncOperation.ClearPodcastData) } } // Suspend versions for direct calls suspend fun syncLikedSongsSuspend() = executeSyncLikedSongs() suspend fun syncLibrarySongsSuspend() = executeSyncLibrarySongs() suspend fun syncUploadedSongsSuspend() = executeSyncUploadedSongs() suspend fun syncLikedAlbumsSuspend() = executeSyncLikedAlbums() suspend fun syncUploadedAlbumsSuspend() = executeSyncUploadedAlbums() suspend fun syncArtistsSubscriptionsSuspend() = executeSyncArtistsSubscriptions() suspend fun syncPodcastSubscriptionsSuspend() = executeSyncPodcastSubscriptions() suspend fun syncEpisodesForLaterSuspend() = executeSyncEpisodesForLater() suspend fun syncSavedPlaylistsSuspend() = executeSyncSavedPlaylists() suspend fun syncAutoSyncPlaylistsSuspend() = executeSyncAutoSyncPlaylists() suspend fun cleanupDuplicatePlaylistsSuspend() = executeCleanupDuplicatePlaylists() suspend fun clearAllSyncedContentSuspend() = executeClearAllSyncedContent() suspend fun clearAllLibraryData() = withContext(Dispatchers.IO) { Timber.d("[LOGOUT_CLEAR] Starting complete library data cleanup") try { // Clear podcast data first Timber.d("[LOGOUT_CLEAR] Clearing podcast data") executeClearPodcastData() // Clear history Timber.d("[LOGOUT_CLEAR] Clearing listen history and search history") database.clearListenHistory() database.clearSearchHistory() // Get all user tables from the database (auto-detect) val allTables = getAllUserTables() Timber.d("[LOGOUT_CLEAR] Found ${allTables.size} tables: $allTables") // Tables to skip (system tables and tables we handle specially) val skipTables = setOf( "android_metadata", "room_master_table", "sqlite_sequence", "search_history", // Already cleared above "listen_history" // Already cleared above ) // Tables with foreign key references - delete these first (mapping tables) val mappingTables = listOf( "playlist_song_map", "song_album_map", "song_artist_map", "album_artist_map", "related_song_map" ) // Delete mapping tables first Timber.d("[LOGOUT_CLEAR] Deleting mapping tables") for (table in mappingTables) { if (table in allTables) { safeDeleteTable(table) } } // Delete all other tables except song (handled specially to keep downloads) Timber.d("[LOGOUT_CLEAR] Deleting remaining tables") for (table in allTables) { if (table in skipTables || table in mappingTables || table == "song") { continue } safeDeleteTable(table) } // Finally, delete songs but keep downloaded ones if ("song" in allTables) { Timber.d("[LOGOUT_CLEAR] Deleting songs (keeping downloaded)") safeRawQuery("DELETE FROM song WHERE dateDownload IS NULL") } Timber.d("[LOGOUT_CLEAR] All library data cleared successfully") } catch (e: Exception) { Timber.e(e, "[LOGOUT_CLEAR] Error clearing library data") throw e } } private fun getAllUserTables(): List { val tables = mutableListOf() try { database.openHelper.writableDatabase.query( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" ).use { cursor -> while (cursor.moveToNext()) { tables.add(cursor.getString(0)) } } } catch (e: Exception) { Timber.e(e, "[LOGOUT_CLEAR] Error getting table list") } return tables } private fun safeDeleteTable(tableName: String) { try { database.raw(androidx.sqlite.db.SimpleSQLiteQuery("DELETE FROM $tableName")) Timber.d("[LOGOUT_CLEAR] Cleared table: $tableName") } catch (e: Exception) { Timber.w("[LOGOUT_CLEAR] Table $tableName error: ${e.message}") } } private fun safeRawQuery(query: String) { try { database.raw(androidx.sqlite.db.SimpleSQLiteQuery(query)) Timber.d("[LOGOUT_CLEAR] Executed: $query") } catch (e: Exception) { Timber.w("[LOGOUT_CLEAR] Query failed: $query - ${e.message}") } } suspend fun syncAllAlbumsSuspend() { executeSyncLikedAlbums() executeSyncUploadedAlbums() } suspend fun syncAllArtistsSuspend() { executeSyncArtistsSubscriptions() } // Private execution methods private suspend fun executeFullSync() = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping full sync - user not logged in") return@withContext } updateState { copy(overallStatus = SyncStatus.Syncing, currentOperation = "Starting full sync") } try { // Sync in sequence to avoid overwhelming the API and database executeSyncLikedSongs() delay(DB_OPERATION_DELAY_MS) executeSyncLibrarySongs() delay(DB_OPERATION_DELAY_MS) executeSyncUploadedSongs() delay(DB_OPERATION_DELAY_MS) executeSyncLikedAlbums() delay(DB_OPERATION_DELAY_MS) executeSyncUploadedAlbums() delay(DB_OPERATION_DELAY_MS) executeSyncArtistsSubscriptions() delay(DB_OPERATION_DELAY_MS) executeSyncPodcastSubscriptions() delay(DB_OPERATION_DELAY_MS) executeSyncEpisodesForLater() delay(DB_OPERATION_DELAY_MS) executeSyncSavedPlaylists() delay(DB_OPERATION_DELAY_MS) executeSyncAutoSyncPlaylists() updateState { copy(overallStatus = SyncStatus.Completed, currentOperation = "") } Timber.d("Full sync completed successfully") } catch (e: CancellationException) { throw e } catch (e: Exception) { Timber.e(e, "Error during full sync") updateState { copy(overallStatus = SyncStatus.Error(e.message ?: "Unknown error"), currentOperation = "") } } } private suspend fun executeLikeSong(s: SongEntity) = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping likeSong - user not logged in") return@withContext } withRetry { YouTube.likeVideo(s.id, s.liked) }.onFailure { e -> Timber.e(e, "Failed to like song on YouTube: ${s.id}") } if (lastfmSendLikes) { try { val dbSong = database.song(s.id).firstOrNull() LastFM.setLoveStatus( artist = dbSong?.artists?.joinToString { a -> a.name } ?: "", track = s.title, love = s.liked ) } catch (e: Exception) { Timber.e(e, "Failed to update LastFM love status") } } } private suspend fun executeSubscribeChannel(channelId: String, subscribe: Boolean) = withContext(Dispatchers.IO) { Timber.d("[CHANNEL_TOGGLE] executeSubscribeChannel called: channelId=$channelId, subscribe=$subscribe") if (!isLoggedIn()) { Timber.d("[CHANNEL_TOGGLE] Skipping subscribeChannel - user not logged in") return@withContext } Timber.d("[CHANNEL_TOGGLE] User is logged in, calling YouTube.subscribeChannel") withRetry { YouTube.subscribeChannel(channelId, subscribe) }.onSuccess { Timber.d("[CHANNEL_TOGGLE] Successfully subscribed/unsubscribed channel: $channelId") PodcastRefreshTrigger.triggerRefresh() }.onFailure { e -> Timber.e(e, "[CHANNEL_TOGGLE] Failed to subscribe/unsubscribe channel: $channelId") } } private suspend fun executeSavePodcast(podcastId: String, save: Boolean) = withContext(Dispatchers.IO) { Timber.d("[PODCAST_TOGGLE] executeSavePodcast called: podcastId=$podcastId, save=$save") if (!isLoggedIn()) { Timber.d("[PODCAST_TOGGLE] Skipping savePodcast - user not logged in") return@withContext } Timber.d("[PODCAST_TOGGLE] User is logged in, calling YouTube.savePodcast") withRetry { YouTube.savePodcast(podcastId, save) }.onSuccess { Timber.d("[PODCAST_TOGGLE] Successfully saved/unsaved podcast: $podcastId") }.onFailure { e -> Timber.e(e, "[PODCAST_TOGGLE] Failed to save/unsave podcast: $podcastId") } } private suspend fun executeSaveEpisode(episodeId: String, save: Boolean, setVideoId: String?) = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.d("Skipping saveEpisode - user not logged in") return@withContext } if (save) { withRetry { YouTube.addEpisodeToSavedEpisodes(episodeId) }.onFailure { e -> Timber.e(e, "Failed to save episode: $episodeId") } } else { if (setVideoId != null) { withRetry { YouTube.removeEpisodeFromSavedEpisodes(episodeId, setVideoId) }.onFailure { e -> Timber.e(e, "Failed to remove episode: $episodeId") } } } } private suspend fun executeSyncLikedSongs() = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping syncLikedSongs - user not logged in") return@withContext } updateState { copy(likedSongs = SyncStatus.Syncing, currentOperation = "Syncing liked songs") } withRetry { YouTube.playlist("LM").completed() }.onSuccess { result -> result.onSuccess { page -> try { val remoteSongs = page.songs val remoteIds = remoteSongs.map { it.id }.toSet() val localSongs = database.likedSongsByNameAsc().first() // Remove likes from songs not in remote localSongs.filterNot { it.id in remoteIds }.forEach { song -> try { database.update(song.song.localToggleLike()) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to update song: ${song.id}") } } // Add/update songs from remote val now = LocalDateTime.now() remoteSongs.forEachIndexed { index, song -> try { val dbSong = database.song(song.id).firstOrNull() val timestamp = now.minusSeconds(index.toLong()) val isVideoSong = song.isVideoSong database.transaction { if (dbSong == null) { insert(song.toMediaMetadata()) { it.copy(liked = true, likedDate = timestamp, isVideo = isVideoSong) } } else if (!dbSong.song.liked || dbSong.song.likedDate != timestamp || dbSong.song.isVideo != isVideoSong) { update(dbSong.song.copy(liked = true, likedDate = timestamp, isVideo = isVideoSong)) } } delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to process song: ${song.id}") } } updateState { copy(likedSongs = SyncStatus.Completed) } Timber.d("Synced ${remoteSongs.size} liked songs") } catch (e: Exception) { Timber.e(e, "Error processing liked songs") updateState { copy(likedSongs = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to fetch liked songs from YouTube") updateState { copy(likedSongs = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to sync liked songs after retries") updateState { copy(likedSongs = SyncStatus.Error(e.message ?: "Unknown error")) } } } private suspend fun executeSyncLibrarySongs() = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping syncLibrarySongs - user not logged in") return@withContext } updateState { copy(librarySongs = SyncStatus.Syncing, currentOperation = "Syncing library songs") } withRetry { YouTube.library("FEmusic_liked_videos").completed() }.onSuccess { result -> result.onSuccess { page -> try { val remoteSongs = page.items.filterIsInstance().reversed() val remoteIds = remoteSongs.map { it.id }.toSet() val localSongs = database.songsByNameAsc().first() localSongs.filterNot { it.id in remoteIds }.forEach { song -> try { database.update(song.song.toggleLibrary()) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to update song: ${song.id}") } } remoteSongs.forEach { song -> try { val dbSong = database.song(song.id).firstOrNull() database.transaction { if (dbSong == null) { insert(song.toMediaMetadata()) { it.toggleLibrary() } } else if (dbSong.song.inLibrary == null) { update(dbSong.song.toggleLibrary()) } } delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to process song: ${song.id}") } } updateState { copy(librarySongs = SyncStatus.Completed) } Timber.d("Synced ${remoteSongs.size} library songs") } catch (e: Exception) { Timber.e(e, "Error processing library songs") updateState { copy(librarySongs = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to fetch library songs from YouTube") updateState { copy(librarySongs = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to sync library songs after retries") updateState { copy(librarySongs = SyncStatus.Error(e.message ?: "Unknown error")) } } } private suspend fun executeSyncUploadedSongs() = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping syncUploadedSongs - user not logged in") return@withContext } updateState { copy(uploadedSongs = SyncStatus.Syncing, currentOperation = "Syncing uploaded songs") } withRetry { // Uploaded songs are in Tab 1 ("Uploads"), not Tab 0 ("Library") YouTube.library("FEmusic_library_privately_owned_tracks", tabIndex = 1).completed() }.onSuccess { result -> result.onSuccess { page -> try { val remoteSongs = page.items.filterIsInstance().reversed() val remoteIds = remoteSongs.map { it.id }.toSet() val localSongs = database.uploadedSongsByNameAsc().first() // Remove uploaded flag from songs no longer in remote localSongs.filterNot { it.id in remoteIds }.forEach { song -> try { database.update(song.song.toggleUploaded()) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to update song: ${song.id}") } } // Sync remote songs to local database remoteSongs.forEach { song -> try { val dbSong = database.song(song.id).firstOrNull() database.transaction { if (dbSong == null) { insert(song.toMediaMetadata()) { it.toggleUploaded() } } else if (!dbSong.song.isUploaded) { update(dbSong.song.copy(isUploaded = true, uploadEntityId = song.uploadEntityId)) } else if (dbSong.song.uploadEntityId != song.uploadEntityId && song.uploadEntityId != null) { // Update uploadEntityId if it differs from remote update(dbSong.song.copy(uploadEntityId = song.uploadEntityId)) } } delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to process song: ${song.id}") } } updateState { copy(uploadedSongs = SyncStatus.Completed) } Timber.d("Synced ${remoteSongs.size} uploaded songs") } catch (e: Exception) { Timber.e(e, "Error processing uploaded songs") updateState { copy(uploadedSongs = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to fetch uploaded songs from YouTube") updateState { copy(uploadedSongs = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to sync uploaded songs after retries") updateState { copy(uploadedSongs = SyncStatus.Error(e.message ?: "Unknown error")) } } } private suspend fun executeSyncLikedAlbums() = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping syncLikedAlbums - user not logged in") return@withContext } updateState { copy(likedAlbums = SyncStatus.Syncing, currentOperation = "Syncing liked albums") } withRetry { YouTube.library("FEmusic_liked_albums").completed() }.onSuccess { result -> result.onSuccess { page -> try { val remoteAlbums = page.items.filterIsInstance().reversed() val remoteIds = remoteAlbums.map { it.id }.toSet() val localAlbums = database.albumsLikedByNameAsc().first() localAlbums.filterNot { it.id in remoteIds }.forEach { album -> try { database.update(album.album.localToggleLike()) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to update album: ${album.id}") } } remoteAlbums.forEach { album -> try { val dbAlbum = database.album(album.id).firstOrNull() YouTube.album(album.browseId).onSuccess { albumPage -> if (dbAlbum == null) { database.insert(albumPage) database.album(album.id).firstOrNull()?.let { newDbAlbum -> database.update(newDbAlbum.album.localToggleLike()) } } else if (dbAlbum.album.bookmarkedAt == null) { database.update(dbAlbum.album.localToggleLike()) } } delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to process album: ${album.id}") } } updateState { copy(likedAlbums = SyncStatus.Completed) } Timber.d("Synced ${remoteAlbums.size} liked albums") } catch (e: Exception) { Timber.e(e, "Error processing liked albums") updateState { copy(likedAlbums = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to fetch liked albums from YouTube") updateState { copy(likedAlbums = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to sync liked albums after retries") updateState { copy(likedAlbums = SyncStatus.Error(e.message ?: "Unknown error")) } } } private suspend fun executeSyncUploadedAlbums() = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping syncUploadedAlbums - user not logged in") return@withContext } updateState { copy(uploadedAlbums = SyncStatus.Syncing, currentOperation = "Syncing uploaded albums") } withRetry { YouTube.library("FEmusic_library_privately_owned_releases").completed() }.onSuccess { result -> result.onSuccess { page -> try { val remoteAlbums = page.items.filterIsInstance().reversed() val remoteIds = remoteAlbums.map { it.id }.toSet() val localAlbums = database.albumsUploadedByNameAsc().first() localAlbums.filterNot { it.id in remoteIds }.forEach { album -> try { database.update(album.album.toggleUploaded()) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to update album: ${album.id}") } } remoteAlbums.forEach { album -> try { val dbAlbum = database.album(album.id).firstOrNull() YouTube.album(album.browseId).onSuccess { albumPage -> if (dbAlbum == null) { database.insert(albumPage) database.album(album.id).firstOrNull()?.let { newDbAlbum -> database.update(newDbAlbum.album.toggleUploaded()) } } else if (!dbAlbum.album.isUploaded) { database.update(dbAlbum.album.toggleUploaded()) } }.onFailure { reportException(it) } delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to process album: ${album.id}") } } updateState { copy(uploadedAlbums = SyncStatus.Completed) } Timber.d("Synced ${remoteAlbums.size} uploaded albums") } catch (e: Exception) { Timber.e(e, "Error processing uploaded albums") updateState { copy(uploadedAlbums = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to fetch uploaded albums from YouTube") updateState { copy(uploadedAlbums = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to sync uploaded albums after retries") updateState { copy(uploadedAlbums = SyncStatus.Error(e.message ?: "Unknown error")) } } } private suspend fun executeSyncArtistsSubscriptions() = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping syncArtistsSubscriptions - user not logged in") return@withContext } updateState { copy(artists = SyncStatus.Syncing, currentOperation = "Syncing artist subscriptions") } withRetry { YouTube.library("FEmusic_library_corpus_artists").completed() }.onSuccess { result -> result.onSuccess { page -> try { val remoteArtists = page.items.filterIsInstance() val remoteIds = remoteArtists.map { it.id }.toSet() val localArtists = database.artistsBookmarkedByNameAsc().first() localArtists.filterNot { it.id in remoteIds }.forEach { artist -> try { database.update(artist.artist.localToggleLike()) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to update artist: ${artist.id}") } } remoteArtists.forEach { artist -> try { val dbArtist = database.artist(artist.id).firstOrNull() val channelId = artist.channelId ?: if (artist.id.startsWith("UC")) { try { YouTube.getChannelId(artist.id).takeIf { it.isNotEmpty() } } catch (e: Exception) { null } } else null database.transaction { if (dbArtist == null) { insert( ArtistEntity( id = artist.id, name = artist.title, thumbnailUrl = artist.thumbnail, channelId = channelId, bookmarkedAt = LocalDateTime.now() ) ) } else { val existing = dbArtist.artist val needsChannelIdUpdate = existing.channelId == null && channelId != null if (existing.bookmarkedAt == null || needsChannelIdUpdate || existing.name != artist.title || existing.thumbnailUrl != artist.thumbnail) { update( existing.copy( name = artist.title, thumbnailUrl = artist.thumbnail, channelId = channelId ?: existing.channelId, bookmarkedAt = existing.bookmarkedAt ?: LocalDateTime.now(), lastUpdateTime = LocalDateTime.now() ) ) } } } delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to process artist: ${artist.id}") } } updateState { copy(artists = SyncStatus.Completed) } Timber.d("Synced ${remoteArtists.size} artist subscriptions") } catch (e: Exception) { Timber.e(e, "Error processing artist subscriptions") updateState { copy(artists = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to fetch artist subscriptions from YouTube") updateState { copy(artists = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to sync artist subscriptions after retries") updateState { copy(artists = SyncStatus.Error(e.message ?: "Unknown error")) } } } private suspend fun executeSyncPodcastSubscriptions() = withContext(Dispatchers.IO) { Timber.d("[PODCAST_SYNC] executeSyncPodcastSubscriptions() started") if (!isLoggedIn()) { Timber.w("[PODCAST_SYNC] Skipping syncPodcastSubscriptions - user not logged in") return@withContext } Timber.d("[PODCAST_SYNC] User is logged in, proceeding with sync") updateState { copy(currentOperation = "Syncing podcast subscriptions") } // Sync saved podcast shows (most common - saved via likePlaylist) withRetry { Timber.d("[PODCAST_SYNC] Calling YouTube.savedPodcastShows()") YouTube.savedPodcastShows() }.onSuccess { result -> Timber.d("[PODCAST_SYNC] savedPodcastShows succeeded, result isSuccess=${result.isSuccess}") result.onSuccess { remotePodcasts -> try { Timber.d("[PODCAST_SYNC] Fetched ${remotePodcasts.size} saved podcast shows") remotePodcasts.forEachIndexed { index, podcast -> Timber.d("[PODCAST_SYNC] Remote podcast $index: id=${podcast.id}, title=${podcast.title}, author=${podcast.author?.name}") } // Server-first: YouTube Music is the source of truth // Add/update podcasts from remote remotePodcasts.forEach { podcast -> try { val dbPodcast = database.podcast(podcast.id).firstOrNull() Timber.d("[PODCAST_SYNC] Processing remote podcast ${podcast.id}: exists in db=${dbPodcast != null}, isSubscribed=${dbPodcast?.bookmarkedAt != null}") database.transaction { if (dbPodcast == null) { // Only add truly new podcasts from server Timber.d("[PODCAST_SYNC] Inserting new podcast: ${podcast.id}") insert( PodcastEntity( id = podcast.id, title = podcast.title, author = podcast.author?.name, thumbnailUrl = podcast.thumbnail, channelId = podcast.channelId ?: podcast.author?.id, bookmarkedAt = LocalDateTime.now(), ) ) } else if (dbPodcast.bookmarkedAt != null) { // Update metadata for already-saved podcasts, but don't re-bookmark // ones that user has removed locally (respect local state) Timber.d("[PODCAST_SYNC] Updating metadata for saved podcast: ${podcast.id}") update( dbPodcast.copy( title = podcast.title, author = podcast.author?.name, thumbnailUrl = podcast.thumbnail, channelId = podcast.channelId ?: podcast.author?.id ?: dbPodcast.channelId, lastUpdateTime = LocalDateTime.now(), ) ) } else { // Podcast exists locally but is unbookmarked - user removed it // Don't re-add; the server removal is likely still pending Timber.d("[PODCAST_SYNC] Skipping unbookmarked podcast: ${podcast.id}") } } delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "[PODCAST_SYNC] Failed to process podcast: ${podcast.id}") } } Timber.d("[PODCAST_SYNC] Synced ${remotePodcasts.size} saved podcast shows successfully") } catch (e: Exception) { Timber.e(e, "[PODCAST_SYNC] Error processing saved podcast shows") } }.onFailure { e -> Timber.e(e, "[PODCAST_SYNC] Failed to fetch saved podcast shows from YouTube") } }.onFailure { e -> Timber.e(e, "[PODCAST_SYNC] Failed to sync saved podcast shows after retries") } // Also sync subscribed podcast channels (subscribed via subscribeChannel API) withRetry { Timber.d("[PODCAST_SYNC] Calling YouTube.libraryPodcastChannels()") YouTube.libraryPodcastChannels() }.onSuccess { result -> Timber.d("[PODCAST_SYNC] libraryPodcastChannels succeeded, result isSuccess=${result.isSuccess}") result.onSuccess { page -> try { val remotePodcasts = page.items.filterIsInstance() Timber.d("[PODCAST_SYNC] Fetched ${remotePodcasts.size} subscribed podcast channels") // Add/update podcasts from remote channels remotePodcasts.forEach { podcast -> try { val dbPodcast = database.podcast(podcast.id).firstOrNull() Timber.d("[PODCAST_SYNC] Processing subscribed channel ${podcast.id}: exists in db=${dbPodcast != null}") database.transaction { if (dbPodcast == null) { // Only add truly new podcasts from server Timber.d("[PODCAST_SYNC] Inserting new subscribed channel: ${podcast.id}") insert( PodcastEntity( id = podcast.id, title = podcast.title, author = podcast.author?.name, thumbnailUrl = podcast.thumbnail, channelId = podcast.channelId ?: podcast.author?.id, bookmarkedAt = LocalDateTime.now(), ) ) } else if (dbPodcast.bookmarkedAt != null) { // Update metadata for already-saved podcasts Timber.d("[PODCAST_SYNC] Updating metadata for subscribed channel: ${podcast.id}") update( dbPodcast.copy( title = podcast.title, author = podcast.author?.name, thumbnailUrl = podcast.thumbnail, channelId = podcast.channelId ?: podcast.author?.id ?: dbPodcast.channelId, lastUpdateTime = LocalDateTime.now(), ) ) } else { // Podcast exists locally but is unbookmarked - don't re-add Timber.d("[PODCAST_SYNC] Skipping unbookmarked channel: ${podcast.id}") } } delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "[PODCAST_SYNC] Failed to process subscribed channel: ${podcast.id}") } } Timber.d("[PODCAST_SYNC] Synced ${remotePodcasts.size} subscribed podcast channels successfully") } catch (e: Exception) { Timber.e(e, "[PODCAST_SYNC] Error processing subscribed podcast channels") } }.onFailure { e -> Timber.e(e, "[PODCAST_SYNC] Failed to fetch subscribed podcast channels from YouTube") } }.onFailure { e -> Timber.e(e, "[PODCAST_SYNC] Failed to sync subscribed podcast channels after retries") } // Cleanup: Remove local podcasts that are no longer subscribed on YouTube Music try { val allRemoteIds = mutableSetOf() // Collect all remote podcast IDs YouTube.savedPodcastShows().onSuccess { podcasts -> allRemoteIds.addAll(podcasts.map { it.id }) } YouTube.libraryPodcastChannels().onSuccess { page -> allRemoteIds.addAll(page.items.filterIsInstance().map { it.id }) } if (allRemoteIds.isNotEmpty()) { val localPodcasts = database.subscribedPodcasts().first() val localOnlyPodcasts = localPodcasts.filterNot { it.id in allRemoteIds } Timber.d("[PODCAST_SYNC] Cleanup: removing ${localOnlyPodcasts.size} podcasts not on YTM") localOnlyPodcasts.forEach { podcast -> try { // Remove subscription (set bookmarkedAt to null) database.transaction { update(podcast.copy(bookmarkedAt = null)) } Timber.d("[PODCAST_SYNC] Unsubscribed from local podcast: ${podcast.id}") } catch (e: Exception) { Timber.e(e, "[PODCAST_SYNC] Failed to cleanup podcast: ${podcast.id}") } } } } catch (e: Exception) { Timber.e(e, "[PODCAST_SYNC] Error during cleanup") } } private suspend fun executeSyncEpisodesForLater() = withContext(Dispatchers.IO) { Timber.d("[EPISODES_SYNC] executeSyncEpisodesForLater() started") if (!isLoggedIn()) { Timber.w("[EPISODES_SYNC] Skipping syncEpisodesForLater - user not logged in") return@withContext } Timber.d("[EPISODES_SYNC] User is logged in, proceeding with sync") updateState { copy(currentOperation = "Syncing episodes for later") } withRetry { Timber.d("[EPISODES_SYNC] Calling YouTube.episodesForLater() (VLSE playlist)") YouTube.episodesForLater() }.onSuccess { result -> result.onSuccess { remoteEpisodes -> try { Timber.d("[EPISODES_SYNC] Fetched ${remoteEpisodes.size} episodes from VLSE playlist") val remoteIds = remoteEpisodes.map { it.id }.toSet() // Get local episodes that are saved (for cleanup later) val localSavedEpisodes = database.podcastEpisodesByCreateDateAsc().first() .filter { it.song.inLibrary != null } Timber.d("[EPISODES_SYNC] Local saved episodes: ${localSavedEpisodes.size}") // Server-first: YouTube Music is the source of truth // Sync remote episodes to local database remoteEpisodes.forEach { episode -> try { val dbSong = database.song(episode.id).firstOrNull() Timber.d("[EPISODES_SYNC] Processing remote episode ${episode.id}: exists in db=${dbSong != null}") database.transaction { if (dbSong == null) { Timber.d("[EPISODES_SYNC] Inserting new episode: ${episode.id}") val mediaMetadata = episode.toMediaMetadata() insert(mediaMetadata.toSongEntity().copy( inLibrary = LocalDateTime.now(), isEpisode = true )) // Insert artists mediaMetadata.artists.forEach { artist -> artist.id?.let { artistId -> insert( ArtistEntity( id = artistId, name = artist.name, ) ) } } } else if (!dbSong.song.isEpisode || dbSong.song.inLibrary == null) { Timber.d("[EPISODES_SYNC] Updating existing song to episode in library: ${episode.id}") update( dbSong.song.copy( isEpisode = true, inLibrary = dbSong.song.inLibrary ?: LocalDateTime.now(), libraryAddToken = episode.libraryAddToken ?: dbSong.song.libraryAddToken, libraryRemoveToken = episode.libraryRemoveToken ?: dbSong.song.libraryRemoveToken, ) ) } else { // Update tokens if we got new ones if (episode.libraryAddToken != null || episode.libraryRemoveToken != null) { update( dbSong.song.copy( libraryAddToken = episode.libraryAddToken ?: dbSong.song.libraryAddToken, libraryRemoveToken = episode.libraryRemoveToken ?: dbSong.song.libraryRemoveToken, ) ) } Timber.d("[EPISODES_SYNC] Episode already in library: ${episode.id}") } // Store setVideoId for removal capability episode.setVideoId?.let { svid -> Timber.d("[EPISODES_SYNC] Storing setVideoId for ${episode.id}: $svid") insert(SetVideoIdEntity(videoId = episode.id, setVideoId = svid)) } } delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "[EPISODES_SYNC] Failed to process episode: ${episode.id}") } } // Cleanup: Remove local episodes that are no longer in Episodes for Later val localToRemove = localSavedEpisodes.filterNot { it.id in remoteIds } Timber.d("[EPISODES_SYNC] Cleanup: removing ${localToRemove.size} episodes not in VLSE") localToRemove.forEach { song -> try { database.transaction { update(song.song.copy(inLibrary = null)) } Timber.d("[EPISODES_SYNC] Removed episode from library: ${song.id}") } catch (e: Exception) { Timber.e(e, "[EPISODES_SYNC] Failed to cleanup episode: ${song.id}") } } Timber.d("[EPISODES_SYNC] Synced ${remoteEpisodes.size} episodes successfully") } catch (e: Exception) { Timber.e(e, "[EPISODES_SYNC] Error processing episodes") } }.onFailure { e -> Timber.e(e, "[EPISODES_SYNC] Failed to fetch episodes from YouTube") } }.onFailure { e -> Timber.e(e, "[EPISODES_SYNC] Failed to sync episodes after retries") } } private suspend fun executeSyncSavedPlaylists() = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping syncSavedPlaylists - user not logged in") return@withContext } updateState { copy(playlists = SyncStatus.Syncing, currentOperation = "Syncing saved playlists") } withRetry { YouTube.library("FEmusic_liked_playlists").completed() }.onSuccess { result -> result.onSuccess { page -> try { val remotePlaylists = page.items.filterIsInstance() .filterNot { it.id == "LM" || it.id == "SE" } .reversed() val remoteIds = remotePlaylists.map { it.id }.toSet() val localPlaylists = database.playlistsByNameAsc().first() localPlaylists.filterNot { it.playlist.browseId in remoteIds } .filterNot { it.playlist.browseId == null } .forEach { playlist -> try { database.update(playlist.playlist.localToggleLike()) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to update playlist: ${playlist.id}") } } for (playlist in remotePlaylists) { try { var playlistEntity = localPlaylists.find { it.playlist.browseId == playlist.id }?.playlist if (playlistEntity == null) { playlistEntity = PlaylistEntity( name = playlist.title, browseId = playlist.id, thumbnailUrl = playlist.thumbnail, isEditable = playlist.isEditable, bookmarkedAt = LocalDateTime.now(), remoteSongCount = playlist.songCountText?.let { Regex("""\d+""").find(it)?.value?.toIntOrNull() }, playEndpointParams = playlist.playEndpoint?.params, shuffleEndpointParams = playlist.shuffleEndpoint?.params, radioEndpointParams = playlist.radioEndpoint?.params ) database.insert(playlistEntity) Timber.d("syncSavedPlaylists: Created new playlist ${playlist.title} (${playlist.id})") } else { database.update(playlistEntity, playlist) Timber.d("syncSavedPlaylists: Updated existing playlist ${playlist.title} (${playlist.id})") } executeSyncPlaylist(playlist.id, playlistEntity.id) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to sync playlist ${playlist.title}") } } updateState { copy(playlists = SyncStatus.Completed) } Timber.d("Synced ${remotePlaylists.size} saved playlists") } catch (e: Exception) { Timber.e(e, "Error processing saved playlists") updateState { copy(playlists = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "syncSavedPlaylists: Failed to fetch playlists from YouTube") updateState { copy(playlists = SyncStatus.Error(e.message ?: "Unknown error")) } } }.onFailure { e -> Timber.e(e, "Failed to sync saved playlists after retries") updateState { copy(playlists = SyncStatus.Error(e.message ?: "Unknown error")) } } } private suspend fun executeSyncAutoSyncPlaylists() = withContext(Dispatchers.IO) { if (!isLoggedIn()) { Timber.w("Skipping syncAutoSyncPlaylists - user not logged in") return@withContext } try { val autoSyncPlaylists = database.playlistsByNameAsc().first() .filter { it.playlist.isAutoSync && it.playlist.browseId != null } Timber.d("syncAutoSyncPlaylists: Found ${autoSyncPlaylists.size} playlists to sync") autoSyncPlaylists.forEach { playlist -> try { executeSyncPlaylist(playlist.playlist.browseId!!, playlist.playlist.id) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to sync playlist ${playlist.playlist.name}") } } } catch (e: Exception) { Timber.e(e, "Error syncing auto-sync playlists") } } private suspend fun executeSyncPlaylist(browseId: String, playlistId: String) = withContext(Dispatchers.IO) { Timber.d("syncPlaylist: Starting sync for browseId=$browseId, playlistId=$playlistId") withRetry { YouTube.playlist(browseId).completed() }.onSuccess { result -> result.onSuccess { page -> try { val songs = page.songs.map(SongItem::toMediaMetadata) Timber.d("syncPlaylist: Fetched ${songs.size} songs from remote") if (songs.isEmpty()) { Timber.w("syncPlaylist: Remote playlist is empty, skipping sync") return@onSuccess } val remoteIds = songs.map { it.id } val localIds = database.playlistSongs(playlistId).first() .sortedBy { it.map.position } .map { it.song.id } if (remoteIds == localIds) { Timber.d("syncPlaylist: Local and remote are in sync, no changes needed") return@onSuccess } Timber.d("syncPlaylist: Updating local playlist (remote: ${remoteIds.size}, local: ${localIds.size})") database.withTransaction { database.clearPlaylist(playlistId) songs.forEachIndexed { idx, song -> if (database.song(song.id).firstOrNull() == null) { database.insert(song) } database.insert( PlaylistSongMap( songId = song.id, playlistId = playlistId, position = idx, setVideoId = song.setVideoId ) ) } } Timber.d("syncPlaylist: Successfully synced playlist") } catch (e: Exception) { Timber.e(e, "Error processing playlist sync") } }.onFailure { e -> Timber.e(e, "syncPlaylist: Failed to fetch playlist from YouTube") } }.onFailure { e -> Timber.e(e, "syncPlaylist: Failed after retries") } } private suspend fun executeCleanupDuplicatePlaylists() = withContext(Dispatchers.IO) { try { val allPlaylists = database.playlistsByNameAsc().first() val browseIdGroups = allPlaylists .filter { it.playlist.browseId != null } .groupBy { it.playlist.browseId } for ((browseId, playlists) in browseIdGroups) { if (playlists.size > 1) { Timber.w("Found ${playlists.size} duplicate playlists for browseId: $browseId") val toKeep = playlists.maxByOrNull { it.songCount } ?: playlists.first() playlists.filter { it.id != toKeep.id }.forEach { duplicate -> try { Timber.d("Removing duplicate playlist: ${duplicate.playlist.name} (${duplicate.id})") database.clearPlaylist(duplicate.id) database.delete(duplicate.playlist) delay(DB_OPERATION_DELAY_MS) } catch (e: Exception) { Timber.e(e, "Failed to remove duplicate playlist: ${duplicate.id}") } } } } } catch (e: Exception) { Timber.e(e, "Error cleaning up duplicate playlists") } } private suspend fun executeClearAllSyncedContent() = withContext(Dispatchers.IO) { Timber.d("clearAllSyncedContent: Starting cleanup") updateState { copy(overallStatus = SyncStatus.Syncing, currentOperation = "Clearing synced content") } try { database.withTransaction { // Clear liked songs val likedSongs = database.likedSongsByNameAsc().first() likedSongs.forEach { database.update(it.song.copy(liked = false, likedDate = null)) } // Clear library songs val librarySongs = database.songsByNameAsc().first() librarySongs.forEach { if (it.song.inLibrary != null) { database.update(it.song.copy(inLibrary = null)) } } // Clear liked albums val likedAlbums = database.albumsLikedByNameAsc().first() likedAlbums.forEach { database.update(it.album.copy(bookmarkedAt = null)) } // Clear subscribed artists val subscribedArtists = database.artistsBookmarkedByNameAsc().first() subscribedArtists.forEach { database.update(it.artist.copy(bookmarkedAt = null)) } // Delete synced playlists val savedPlaylists = database.playlistsByNameAsc().first() savedPlaylists.forEach { if (it.playlist.browseId != null) { database.clearPlaylist(it.playlist.id) database.delete(it.playlist) } } // Clear uploaded songs val uploadedSongs = database.uploadedSongsByNameAsc().first() uploadedSongs.forEach { database.update(it.song.copy(isUploaded = false, uploadEntityId = null)) } // Clear uploaded albums val uploadedAlbums = database.albumsUploadedByCreateDateAsc().first() uploadedAlbums.forEach { database.update(it.album.copy(isUploaded = false)) } } // Reset sync timestamp context.dataStore.edit { settings -> settings[LastFullSyncKey] = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) } updateState { copy(overallStatus = SyncStatus.Completed, currentOperation = "") } Timber.d("clearAllSyncedContent: Cleanup completed successfully") } catch (e: Exception) { Timber.e(e, "clearAllSyncedContent: Error during cleanup") updateState { copy(overallStatus = SyncStatus.Error(e.message ?: "Unknown error"), currentOperation = "") } } } private suspend fun executeClearPodcastData() = withContext(Dispatchers.IO) { Timber.d("[PODCAST_CLEAR] Starting podcast data cleanup") updateState { copy(overallStatus = SyncStatus.Syncing, currentOperation = "Clearing podcast data") } try { database.withTransaction { // Clear subscribed podcasts val subscribedPodcasts = database.subscribedPodcasts().first() Timber.d("[PODCAST_CLEAR] Clearing ${subscribedPodcasts.size} subscribed podcasts") subscribedPodcasts.forEach { podcast -> database.update(podcast.copy(bookmarkedAt = null)) } // Clear episode library status (inLibrary) for episodes val savedEpisodes = database.podcastEpisodesByCreateDateAsc().first() .filter { it.song.inLibrary != null } Timber.d("[PODCAST_CLEAR] Clearing ${savedEpisodes.size} saved episodes") savedEpisodes.forEach { song -> database.update(song.song.copy(inLibrary = null)) } } updateState { copy(overallStatus = SyncStatus.Completed, currentOperation = "") } Timber.d("[PODCAST_CLEAR] Podcast data cleared successfully") } catch (e: Exception) { Timber.e(e, "[PODCAST_CLEAR] Error during cleanup") updateState { copy(overallStatus = SyncStatus.Error(e.message ?: "Unknown error"), currentOperation = "") } } } fun cancelAllSyncs() { processingJob?.cancel() startProcessingQueue() updateState { SyncState() } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/Updater.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import com.metrolist.music.BuildConfig import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject data class ReleaseInfo( val tagName: String, val versionName: String, val description: String, val releaseDate: String, val assets: List ) data class ReleaseAsset( val name: String, val downloadUrl: String, val size: Long, val architecture: String, val variant: String // "foss" or "gms" ) object Updater { private val client = HttpClient() var lastCheckTime = -1L private set private var cachedReleaseInfo: ReleaseInfo? = null private var cachedAllReleases: List = emptyList() private const val CHECK_INTERVAL_MILLIS = 2 * 60 * 60 * 1000L // 2 hours private const val GITHUB_API_BASE = "https://api.github.com/repos/MetrolistGroup/Metrolist" /** * Compares two version strings. * Returns: 1 if v1 > v2, -1 if v1 < v2, 0 if equal */ fun compareVersions(v1: String, v2: String): Int { val v1Parts = v1.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } val v2Parts = v2.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } val maxLength = maxOf(v1Parts.size, v2Parts.size) for (i in 0 until maxLength) { val part1 = v1Parts.getOrNull(i) ?: 0 val part2 = v2Parts.getOrNull(i) ?: 0 when { part1 > part2 -> return 1 part1 < part2 -> return -1 } } return 0 } /** * Checks if the latest version is newer than the current version. * Returns true if an update is available (latestVersion > currentVersion) */ fun isUpdateAvailable(currentVersion: String, latestVersion: String): Boolean { return compareVersions(latestVersion, currentVersion) > 0 } /** * Get the current app's architecture and variant */ private fun getCurrentAppVariant(): Pair { val architecture = BuildConfig.ARCHITECTURE val variant = if (BuildConfig.CAST_AVAILABLE) "gms" else "foss" return architecture to variant } /** * Parse release assets from GitHub API response */ private fun parseAssets(assetsArray: JSONArray): List { val assets = mutableListOf() for (i in 0 until assetsArray.length()) { val asset = assetsArray.getJSONObject(i) val name = asset.getString("name") // Skip non-APK files if (!name.endsWith(".apk")) continue val downloadUrl = asset.getString("browser_download_url") val size = asset.getLong("size") // Parse architecture and variant from filename val (arch, variant) = when { name == "Metrolist.apk" -> "universal" to "foss" name == "Metrolist-with-Google-Cast.apk" -> "universal" to "gms" name.startsWith("app-") && name.endsWith("-release.apk") -> { val arch = name.removePrefix("app-").removeSuffix("-release.apk") arch to "foss" } name.startsWith("app-") && name.endsWith("-with-Google-Cast.apk") -> { val arch = name.removePrefix("app-").removeSuffix("-with-Google-Cast.apk") arch to "gms" } else -> null to null } if (arch != null && variant != null) { assets.add(ReleaseAsset(name, downloadUrl, size, arch, variant)) } } return assets } /** * Fetch latest release from GitHub API */ suspend fun getLatestRelease(forceRefresh: Boolean = false): Result = withContext(Dispatchers.IO) { runCatching { // Return cached if available and not forcing refresh if (cachedReleaseInfo != null && !forceRefresh) { return@runCatching cachedReleaseInfo!! } val response = client.get("$GITHUB_API_BASE/releases/latest") .bodyAsText() val json = JSONObject(response) val releaseInfo = ReleaseInfo( tagName = json.getString("tag_name"), versionName = json.getString("name"), description = json.getString("body"), releaseDate = json.getString("published_at"), assets = parseAssets(json.getJSONArray("assets")) ) cachedReleaseInfo = releaseInfo lastCheckTime = System.currentTimeMillis() releaseInfo } } /** * Fetch all releases from GitHub API (paginated) */ suspend fun getAllReleases(forceRefresh: Boolean = false): Result> = withContext(Dispatchers.IO) { runCatching { if (cachedAllReleases.isNotEmpty() && !forceRefresh) { return@runCatching cachedAllReleases } val releases = mutableListOf() var page = 1 var hasMore = true while (hasMore && page <= 10) { // Limit to 10 pages val response = client.get("$GITHUB_API_BASE/releases?page=$page&per_page=30") .bodyAsText() val json = JSONArray(response) if (json.length() == 0) { hasMore = false break } for (i in 0 until json.length()) { val releaseObj = json.getJSONObject(i) releases.add(ReleaseInfo( tagName = releaseObj.getString("tag_name"), versionName = releaseObj.getString("name"), description = releaseObj.getString("body"), releaseDate = releaseObj.getString("published_at"), assets = parseAssets(releaseObj.getJSONArray("assets")) )) } page++ } cachedAllReleases = releases releases } } /** * Get the download URL for the correct app variant */ fun getDownloadUrlForCurrentVariant(releaseInfo: ReleaseInfo): String? { val (currentArch, currentVariant) = getCurrentAppVariant() return releaseInfo.assets .find { it.architecture == currentArch && it.variant == currentVariant } ?.downloadUrl } /** * Get all available download URLs for a release */ fun getAllDownloadUrls(releaseInfo: ReleaseInfo): Map { return releaseInfo.assets.associate { "${it.architecture}-${it.variant}" to it.downloadUrl } } /** * Check if update is needed (respects 2-hour cache) */ suspend fun checkForUpdate(forceRefresh: Boolean = false): Result> = withContext(Dispatchers.IO) { runCatching { // Check if we should fetch (2 hour interval) val shouldFetch = forceRefresh || (System.currentTimeMillis() - lastCheckTime) > CHECK_INTERVAL_MILLIS if (!shouldFetch && cachedReleaseInfo != null) { val hasUpdate = isUpdateAvailable( BuildConfig.VERSION_NAME, cachedReleaseInfo!!.versionName ) return@runCatching cachedReleaseInfo!! to hasUpdate } val result = getLatestRelease(forceRefresh = true) if (result.isSuccess) { val releaseInfo = result.getOrThrow() val hasUpdate = isUpdateAvailable( BuildConfig.VERSION_NAME, releaseInfo.versionName ) releaseInfo to hasUpdate } else { throw result.exceptionOrNull() ?: Exception("Unknown error") } } } /** * Get the download URL for the correct app variant * Returns null if no matching asset is found */ fun getLatestDownloadUrl(): String? { return cachedReleaseInfo?.let { getDownloadUrlForCurrentVariant(it) } } /** * Get the latest release info (cached) */ fun getCachedLatestRelease(): ReleaseInfo? = cachedReleaseInfo } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/Utils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import android.content.Context import android.content.res.Configuration import java.util.Locale fun reportException(throwable: Throwable) { throwable.printStackTrace() } @Suppress("DEPRECATION") fun setAppLocale(context: Context, locale: Locale) { val config = Configuration(context.resources.configuration) config.setLocale(locale) context.resources.updateConfiguration(config, context.resources.displayMetrics) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/YTPlayerUtils.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.utils import android.net.ConnectivityManager import android.net.Uri import android.util.Log import androidx.media3.common.PlaybackException import com.metrolist.innertube.NewPipeExtractor import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.YouTubeClient import com.metrolist.innertube.models.YouTubeClient.Companion.ANDROID_CREATOR import com.metrolist.innertube.models.YouTubeClient.Companion.ANDROID_VR_1_43_32 import com.metrolist.innertube.models.YouTubeClient.Companion.ANDROID_VR_1_61_48 import com.metrolist.innertube.models.YouTubeClient.Companion.ANDROID_VR_NO_AUTH import com.metrolist.innertube.models.YouTubeClient.Companion.IOS import com.metrolist.innertube.models.YouTubeClient.Companion.IPADOS import com.metrolist.innertube.models.YouTubeClient.Companion.MOBILE import com.metrolist.innertube.models.YouTubeClient.Companion.TVHTML5 import com.metrolist.innertube.models.YouTubeClient.Companion.TVHTML5_SIMPLY_EMBEDDED_PLAYER import com.metrolist.innertube.models.YouTubeClient.Companion.WEB import com.metrolist.innertube.models.YouTubeClient.Companion.WEB_CREATOR import com.metrolist.innertube.models.YouTubeClient.Companion.WEB_REMIX import com.metrolist.innertube.models.response.PlayerResponse import com.metrolist.music.constants.AudioQuality import com.metrolist.music.utils.cipher.CipherDeobfuscator import com.metrolist.music.utils.YTPlayerUtils.MAIN_CLIENT import com.metrolist.music.utils.YTPlayerUtils.STREAM_FALLBACK_CLIENTS import com.metrolist.music.utils.YTPlayerUtils.validateStatus import com.metrolist.music.utils.potoken.PoTokenGenerator import com.metrolist.music.utils.potoken.PoTokenResult import com.metrolist.music.utils.sabr.EjsNTransformSolver import okhttp3.OkHttpClient import timber.log.Timber object YTPlayerUtils { private const val logTag = "YTPlayerUtils" private const val TAG = "YTPlayerUtils" private val httpClient = OkHttpClient.Builder() .proxy(YouTube.proxy) .build() private val poTokenGenerator = PoTokenGenerator() private val MAIN_CLIENT: YouTubeClient = WEB_REMIX private val STREAM_FALLBACK_CLIENTS: Array = arrayOf( TVHTML5_SIMPLY_EMBEDDED_PLAYER, // Try embedded player first for age-restricted content TVHTML5, ANDROID_VR_1_43_32, ANDROID_VR_1_61_48, ANDROID_CREATOR, IPADOS, ANDROID_VR_NO_AUTH, MOBILE, IOS, WEB, WEB_CREATOR ) data class PlaybackData( val audioConfig: PlayerResponse.PlayerConfig.AudioConfig?, val videoDetails: PlayerResponse.VideoDetails?, val playbackTracking: PlayerResponse.PlaybackTracking?, 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(TAG).d("=== PLAYER RESPONSE FOR PLAYBACK ===") Timber.tag(TAG).d("videoId: $videoId") Timber.tag(TAG).d("playlistId: $playlistId") Timber.tag(TAG).d("audioQuality: $audioQuality") // Check if this is an uploaded/privately owned track val isUploadedTrack = playlistId == "MLPT" || playlistId?.contains("MLPT") == true Timber.tag(TAG).d("Content type detection (preliminary):") Timber.tag(TAG).d(" isUploadedTrack (from playlistId): $isUploadedTrack") val isLoggedIn = YouTube.cookie != null Timber.tag(TAG).d("Authentication status: ${if (isLoggedIn) "LOGGED_IN" else "ANONYMOUS"}") // Get signature timestamp (same as before for normal content) val signatureTimestamp = getSignatureTimestampOrNull(videoId) Timber.tag(logTag).d("Signature timestamp: ${signatureTimestamp.timestamp}") // Generate PoToken var poToken: PoTokenResult? = null val sessionId = if (isLoggedIn) YouTube.dataSyncId else YouTube.visitorData if (MAIN_CLIENT.useWebPoTokens && sessionId != null) { Timber.tag(logTag).d("Generating PoToken for WEB_REMIX with sessionId") try { poToken = poTokenGenerator.getWebClientPoToken(videoId, sessionId) if (poToken != null) { Timber.tag(logTag).d("PoToken generated successfully") } } catch (e: Exception) { Timber.tag(logTag).e(e, "PoToken generation failed: ${e.message}") } } // Try WEB_REMIX with signature timestamp and poToken (same as before) Timber.tag(logTag).d("Attempting to get player response using MAIN_CLIENT: ${MAIN_CLIENT.clientName}") var mainPlayerResponse = YouTube.player(videoId, playlistId, MAIN_CLIENT, signatureTimestamp.timestamp, poToken?.playerRequestPoToken).getOrThrow() // Debug uploaded track response if (isUploadedTrack || playlistId?.contains("MLPT") == true) { println("[PLAYBACK_DEBUG] Main player response status: ${mainPlayerResponse.playabilityStatus.status}") println("[PLAYBACK_DEBUG] Playability reason: ${mainPlayerResponse.playabilityStatus.reason}") println("[PLAYBACK_DEBUG] Video details: title=${mainPlayerResponse.videoDetails?.title}, videoId=${mainPlayerResponse.videoDetails?.videoId}") println("[PLAYBACK_DEBUG] Streaming data null? ${mainPlayerResponse.streamingData == null}") println("[PLAYBACK_DEBUG] Adaptive formats count: ${mainPlayerResponse.streamingData?.adaptiveFormats?.size ?: 0}") } var usedAgeRestrictedClient: YouTubeClient? = null val wasOriginallyAgeRestricted: Boolean // Check if WEB_REMIX response indicates age-restricted val mainStatus = mainPlayerResponse.playabilityStatus.status val isAgeRestrictedFromResponse = mainStatus in listOf("AGE_CHECK_REQUIRED", "AGE_VERIFICATION_REQUIRED", "LOGIN_REQUIRED", "CONTENT_CHECK_REQUIRED") wasOriginallyAgeRestricted = isAgeRestrictedFromResponse if (isAgeRestrictedFromResponse && isLoggedIn) { // Age-restricted: use WEB_CREATOR directly (no NewPipe needed from here) Timber.tag(logTag).d("Age-restricted detected, using WEB_CREATOR") Timber.tag(TAG).i("Age-restricted: using WEB_CREATOR for videoId=$videoId") val creatorResponse = YouTube.player(videoId, playlistId, WEB_CREATOR, null, null).getOrNull() if (creatorResponse?.playabilityStatus?.status == "OK") { Timber.tag(logTag).d("WEB_CREATOR works for age-restricted content") mainPlayerResponse = creatorResponse usedAgeRestrictedClient = WEB_CREATOR } } // If we still don't have a valid response, throw val audioConfig = mainPlayerResponse.playerConfig?.audioConfig val videoDetails = mainPlayerResponse.videoDetails val playbackTracking = mainPlayerResponse.playbackTracking var format: PlayerResponse.StreamingData.Format? = null var streamUrl: String? = null var streamExpiresInSeconds: Int? = null var streamPlayerResponse: PlayerResponse? = null val retryMainPlayerResponse: PlayerResponse? = if (usedAgeRestrictedClient != null) mainPlayerResponse else null // Check current status val currentStatus = mainPlayerResponse.playabilityStatus.status val isAgeRestricted = currentStatus in listOf("AGE_CHECK_REQUIRED", "AGE_VERIFICATION_REQUIRED", "LOGIN_REQUIRED", "CONTENT_CHECK_REQUIRED") if (isAgeRestricted) { Timber.tag(logTag).d("Content is still age-restricted (status: $currentStatus), will try fallback clients") Timber.tag(TAG) .i("Age-restricted content detected: videoId=$videoId, status=$currentStatus") } // Check if this is a privately owned track (uploaded song) val isPrivateTrack = mainPlayerResponse.videoDetails?.musicVideoType == "MUSIC_VIDEO_TYPE_PRIVATELY_OWNED_TRACK" // For private tracks: use TVHTML5 (index 1) with PoToken + n-transform // For age-restricted: skip main client, start with fallbacks // For normal content: standard order val startIndex = when { isPrivateTrack -> 1 // TVHTML5 isAgeRestricted -> 0 else -> -1 } for (clientIndex in (startIndex 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 (use retry response if available) client = MAIN_CLIENT streamPlayerResponse = retryMainPlayerResponse ?: 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}") // Only pass poToken for clients that support it val clientPoToken = if (client.useWebPoTokens) poToken?.playerRequestPoToken else null // Skip signature timestamp for age-restricted (faster), use it for normal content val clientSigTimestamp = if (wasOriginallyAgeRestricted) null else signatureTimestamp.timestamp streamPlayerResponse = YouTube.player(videoId, playlistId, client, clientSigTimestamp, clientPoToken).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}") // Skip NewPipe for age-restricted content (NewPipe doesn't use our auth) val responseToUse = if (wasOriginallyAgeRestricted) { Timber.tag(logTag).d("Skipping NewPipe for age-restricted content") streamPlayerResponse } else { // Try to get streams using newPipePlayer method val newPipeResponse = YouTube.newPipePlayer(videoId, streamPlayerResponse) newPipeResponse ?: streamPlayerResponse } format = findFormat( responseToUse, 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, responseToUse, skipNewPipe = wasOriginallyAgeRestricted) if (streamUrl == null) { Timber.tag(logTag).d("Stream URL not found for format") continue } // Apply n-transform for throttle parameter handling val currentClient = if (clientIndex == -1) { usedAgeRestrictedClient ?: MAIN_CLIENT } else { STREAM_FALLBACK_CLIENTS[clientIndex] } // Check if this is a privately owned track val isPrivatelyOwnedTrack = streamPlayerResponse.videoDetails?.musicVideoType == "MUSIC_VIDEO_TYPE_PRIVATELY_OWNED_TRACK" val musicVideoType = streamPlayerResponse.videoDetails?.musicVideoType Timber.tag(TAG).d("=== N-TRANSFORM DECISION ===") Timber.tag(TAG).d("Content type analysis:") Timber.tag(TAG).d(" musicVideoType: $musicVideoType") Timber.tag(TAG).d(" isPrivatelyOwnedTrack: $isPrivatelyOwnedTrack") Timber.tag(TAG).d(" isUploadedTrack (from playlistId): $isUploadedTrack") Timber.tag(TAG).d(" wasOriginallyAgeRestricted: $wasOriginallyAgeRestricted") Timber.tag(TAG).d("Client analysis:") Timber.tag(TAG).d(" currentClient: ${currentClient.clientName}") Timber.tag(TAG).d(" useWebPoTokens: ${currentClient.useWebPoTokens}") // Apply n-transform and PoToken for web clients OR for private tracks (including TVHTML5) val needsNTransform = currentClient.useWebPoTokens || currentClient.clientName in listOf("WEB", "WEB_REMIX", "WEB_CREATOR", "TVHTML5") || isPrivatelyOwnedTrack Timber.tag(TAG).d("N-transform decision:") Timber.tag(TAG).d(" needsNTransform: $needsNTransform") Timber.tag(TAG).d(" Reason: useWebPoTokens=${currentClient.useWebPoTokens}, " + "clientInList=${currentClient.clientName in listOf("WEB", "WEB_REMIX", "WEB_CREATOR", "TVHTML5")}, " + "isPrivatelyOwnedTrack=$isPrivatelyOwnedTrack") if (needsNTransform) { try { Timber.tag(TAG).d("Applying n-transform to stream URL...") Timber.tag(TAG).d(" Original URL length: ${streamUrl.length}") Timber.tag(TAG).d(" Original URL preview: ${streamUrl.take(100)}...") val originalUrl = streamUrl // Use CipherDeobfuscator for n-transform (fixed implementation) streamUrl = CipherDeobfuscator.transformNParamInUrl(streamUrl) Timber.tag(TAG).d(" Transformed URL length: ${streamUrl.length}") Timber.tag(TAG).d(" URL changed: ${originalUrl != streamUrl}") // Append pot= parameter with streaming data poToken val needsPoToken = (currentClient.useWebPoTokens || isPrivatelyOwnedTrack) && poToken?.streamingDataPoToken != null Timber.tag(TAG).d("PoToken decision:") Timber.tag(TAG).d(" needsPoToken: $needsPoToken") Timber.tag(TAG).d(" hasStreamingDataPoToken: ${poToken?.streamingDataPoToken != null}") if (needsPoToken) { Timber.tag(TAG).d("Appending pot= parameter to stream URL") val separator = if ("?" in streamUrl) "&" else "?" streamUrl = "${streamUrl}${separator}pot=${Uri.encode(poToken!!.streamingDataPoToken)}" Timber.tag(TAG).d(" Final URL length (with pot): ${streamUrl.length}") } } catch (e: Exception) { Timber.tag(TAG).e(e, "N-transform or pot append failed: ${e.message}") Timber.tag(TAG).e("Stack trace: ${e.stackTraceToString().take(500)}") // Continue with original URL } } else { Timber.tag(TAG).d("Skipping n-transform (not required for this client/content)") } 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") // Check if this is a privately owned track (uploaded song) val isPrivatelyOwned = streamPlayerResponse.videoDetails?.musicVideoType == "MUSIC_VIDEO_TYPE_PRIVATELY_OWNED_TRACK" if (clientIndex == STREAM_FALLBACK_CLIENTS.size - 1 || isPrivatelyOwned) { /** skip [validateStatus] for last client or private tracks */ if (isPrivatelyOwned) { Timber.tag(logTag).d("Skipping validation for privately owned track: ${currentClient.clientName}") println("[PLAYBACK_DEBUG] Using stream without validation for PRIVATELY_OWNED_TRACK") } else { Timber.tag(logTag).d("Using last fallback client without validation: ${STREAM_FALLBACK_CLIENTS[clientIndex].clientName}") } Timber.tag(TAG) .i("Playback: client=${currentClient.clientName}, videoId=$videoId, private=$isPrivatelyOwned") break } if (validateStatus(streamUrl)) { // working stream found Timber.tag(logTag).d("Stream validated successfully with client: ${currentClient.clientName}") // Log for release builds Timber.tag(TAG).i("Playback: client=${currentClient.clientName}, videoId=$videoId") break } else { Timber.tag(logTag).d("Stream validation failed for client: ${currentClient.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") if (isUploadedTrack) { println("[PLAYBACK_DEBUG] FAILURE: All clients failed for uploaded track videoId=$videoId") } 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") if (isUploadedTrack) { println("[PLAYBACK_DEBUG] FAILURE: Playability not OK for uploaded track - status=${streamPlayerResponse.playabilityStatus.status}, reason=$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}") if (isUploadedTrack) { println("[PLAYBACK_DEBUG] SUCCESS: Got playback data for uploaded track - format=${format.mimeType}, streamUrl=${streamUrl.take(100)}...") } PlaybackData( audioConfig, videoDetails, playbackTracking, format, streamUrl, streamExpiresInSeconds, ) }.onFailure { e -> println("[PLAYBACK_DEBUG] EXCEPTION during playback for videoId=$videoId: ${e::class.simpleName}: ${e.message}") e.printStackTrace() } /** * 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 = WEB_REMIX) // ANDROID_VR does not work with history .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 && it.isOriginal } ?.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) // Add authentication cookie for privately owned tracks YouTube.cookie?.let { cookie -> requestBuilder.addHeader("Cookie", cookie) println("[PLAYBACK_DEBUG] Added cookie to validation request") } 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 } data class SignatureTimestampResult( val timestamp: Int?, val isAgeRestricted: Boolean ) private fun getSignatureTimestampOrNull(videoId: String): SignatureTimestampResult { Timber.tag(logTag).d("Getting signature timestamp for videoId: $videoId") val result = NewPipeExtractor.getSignatureTimestamp(videoId) return result.fold( onSuccess = { timestamp -> Timber.tag(logTag).d("Signature timestamp obtained: $timestamp") SignatureTimestampResult(timestamp, isAgeRestricted = false) }, onFailure = { error -> val isAgeRestricted = error.message?.contains("age-restricted", ignoreCase = true) == true || error.cause?.message?.contains("age-restricted", ignoreCase = true) == true if (isAgeRestricted) { Timber.tag(logTag).d("Age-restricted content detected from NewPipe") Timber.tag(TAG).i("Age-restricted detected early via NewPipe: videoId=$videoId") } else { Timber.tag(logTag).e(error, "Failed to get signature timestamp") reportException(error) } SignatureTimestampResult(null, isAgeRestricted) } ) } private suspend fun findUrlOrNull( format: PlayerResponse.StreamingData.Format, videoId: String, playerResponse: PlayerResponse, skipNewPipe: Boolean = false ): String? { Timber.tag(logTag).d("Finding stream URL for format: ${format.mimeType}, videoId: $videoId, skipNewPipe: $skipNewPipe") // First check if format already has a URL if (!format.url.isNullOrEmpty()) { Timber.tag(logTag).d("Using URL from format directly") return format.url } // Try custom cipher deobfuscation for signatureCipher formats val signatureCipher = format.signatureCipher ?: format.cipher if (!signatureCipher.isNullOrEmpty()) { Timber.tag(logTag).d("Format has signatureCipher, using custom deobfuscation") val customDeobfuscatedUrl = CipherDeobfuscator.deobfuscateStreamUrl(signatureCipher, videoId) if (customDeobfuscatedUrl != null) { Timber.tag(logTag).d("Stream URL obtained via custom cipher deobfuscation") return customDeobfuscatedUrl } Timber.tag(logTag).d("Custom cipher deobfuscation failed") } // Skip NewPipe for age-restricted content if (skipNewPipe) { Timber.tag(logTag).d("Skipping NewPipe methods for age-restricted content") return null } // Try to get URL using NewPipeExtractor signature deobfuscation val deobfuscatedUrl = NewPipeExtractor.getStreamUrl(format, videoId) if (deobfuscatedUrl != null) { Timber.tag(logTag).d("Stream URL obtained via NewPipe deobfuscation") return deobfuscatedUrl } // Fallback: try to get URL from StreamInfo Timber.tag(logTag).d("Trying StreamInfo fallback for URL") val streamUrls = YouTube.getNewPipeStreamUrls(videoId) if (streamUrls.isNotEmpty()) { val streamUrl = streamUrls.find { it.first == format.itag }?.second if (streamUrl != null) { Timber.tag(logTag).d("Stream URL obtained from StreamInfo") return streamUrl } // If exact itag not found, try to find any audio stream val audioStream = streamUrls.find { urlPair -> playerResponse.streamingData?.adaptiveFormats?.any { it.itag == urlPair.first && it.isAudio } == true }?.second if (audioStream != null) { Timber.tag(logTag).d("Audio stream URL obtained from StreamInfo (different itag)") return audioStream } } Timber.tag(logTag).e("Failed to get stream URL") return null } fun forceRefreshForVideo(videoId: String) { Timber.tag(logTag).d("Force refreshing for videoId: $videoId") } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/cipher/CipherDeobfuscator.kt ================================================ package com.metrolist.music.utils.cipher import android.content.Context import android.net.Uri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import timber.log.Timber /** * Main cipher deobfuscation orchestrator for YouTube stream URLs. * * Handles both signature deobfuscation (for signatureCipher streams) and * n-parameter transformation (for throttle avoidance / 403 fix). */ object CipherDeobfuscator { private const val TAG = "Metrolist_CipherDeobfusc" lateinit var appContext: Context private set fun initialize(context: Context) { Timber.tag(TAG).d("CipherDeobfuscator initializing...") appContext = context.applicationContext Timber.tag(TAG).d("CipherDeobfuscator initialized") } private var cipherWebView: CipherWebView? = null private var currentPlayerHash: String? = null /** * Deobfuscate a signatureCipher stream URL. * * The signatureCipher is a query string containing: * - s: The obfuscated signature * - sp: The signature parameter name (usually "sig" or "signature") * - url: The base stream URL * * Returns the full URL with deobfuscated signature, or null if failed. */ suspend fun deobfuscateStreamUrl(signatureCipher: String, videoId: String): String? { Timber.tag(TAG).d("=== DEOBFUSCATE STREAM URL ===") Timber.tag(TAG).d("videoId: $videoId") Timber.tag(TAG).d("signatureCipher length: ${signatureCipher.length}") Timber.tag(TAG).d("signatureCipher preview: ${signatureCipher.take(100)}...") return try { deobfuscateInternal(signatureCipher, videoId, isRetry = false) } catch (e: Exception) { Timber.tag(TAG).e(e, "Cipher deobfuscation failed, retrying with fresh JS: ${e.message}") Timber.tag(TAG).d("Invalidating cache and retrying...") try { PlayerJsFetcher.invalidateCache() closeWebView() deobfuscateInternal(signatureCipher, videoId, isRetry = true) } catch (retryE: Exception) { Timber.tag(TAG).e(retryE, "Cipher deobfuscation retry also failed: ${retryE.message}") null } } } private suspend fun deobfuscateInternal(signatureCipher: String, videoId: String, isRetry: Boolean): String? { Timber.tag(TAG).d("deobfuscateInternal: videoId=$videoId, isRetry=$isRetry") // Parse the signatureCipher query string val params = parseQueryParams(signatureCipher) val obfuscatedSig = params["s"] val sigParam = params["sp"] ?: "signature" val baseUrl = params["url"] Timber.tag(TAG).d("Parsed signatureCipher params:") Timber.tag(TAG).d(" s (obfuscated sig): ${obfuscatedSig?.take(30)}... (length=${obfuscatedSig?.length})") Timber.tag(TAG).d(" sp (sig param name): $sigParam") Timber.tag(TAG).d(" url: ${baseUrl?.take(80)}...") if (obfuscatedSig == null || baseUrl == null) { Timber.tag(TAG).e("Could not parse signatureCipher params: s=${obfuscatedSig != null}, url=${baseUrl != null}") return null } val webView = getOrCreateWebView(forceRefresh = isRetry) if (webView == null) { Timber.tag(TAG).e("Failed to get/create CipherWebView") return null } Timber.tag(TAG).d("Calling webView.deobfuscateSignature()...") val deobfuscatedSig = webView.deobfuscateSignature(obfuscatedSig) Timber.tag(TAG).d("Deobfuscated signature: ${deobfuscatedSig.take(30)}... (length=${deobfuscatedSig.length})") // Build the URL with deobfuscated signature val separator = if ("?" in baseUrl) "&" else "?" val finalUrl = "$baseUrl${separator}${sigParam}=${Uri.encode(deobfuscatedSig)}" Timber.tag(TAG).d("=== CIPHER DEOBFUSCATION SUCCESS ===") Timber.tag(TAG).d("videoId: $videoId") Timber.tag(TAG).d("Final URL length: ${finalUrl.length}") Timber.tag(TAG).d("Final URL preview: ${finalUrl.take(100)}...") return finalUrl } /** * Transform the 'n' parameter in a streaming URL to avoid throttling/403. * * Uses the runtime-discovered n-function from the player JS WebView. * Returns the URL with the transformed 'n' value, or the original URL if transform fails. * * IMPORTANT: This must be called for WEB_REMIX, WEB, WEB_CREATOR, TVHTML5 clients * and for privately owned tracks (uploaded songs). */ suspend fun transformNParamInUrl(url: String): String { Timber.tag(TAG).d("=== N-TRANSFORM URL ===") Timber.tag(TAG).d("Input URL length: ${url.length}") Timber.tag(TAG).d("Input URL preview: ${url.take(100)}...") return try { transformNInternal(url) } catch (e: Exception) { Timber.tag(TAG).e(e, "N-transform failed, returning original URL: ${e.message}") url } } private suspend fun transformNInternal(url: String): String { // Extract the 'n' parameter value from the URL val nMatch = Regex("[?&]n=([^&]+)").find(url) if (nMatch == null) { Timber.tag(TAG).d("No 'n' parameter found in URL, skipping transform") return url } val nValueEncoded = nMatch.groupValues[1] val nValue = Uri.decode(nValueEncoded) Timber.tag(TAG).d("N-param found:") Timber.tag(TAG).d(" encoded: $nValueEncoded") Timber.tag(TAG).d(" decoded: $nValue") val webView = getOrCreateWebView(forceRefresh = false) if (webView == null) { Timber.tag(TAG).e("Failed to get CipherWebView for n-transform") return url } Timber.tag(TAG).d("CipherWebView state:") Timber.tag(TAG).d(" nFunctionAvailable: ${webView.nFunctionAvailable}") Timber.tag(TAG).d(" discoveredNFuncName: ${webView.discoveredNFuncName}") Timber.tag(TAG).d(" usingHardcodedMode: ${webView.usingHardcodedMode}") if (!webView.nFunctionAvailable) { Timber.tag(TAG).e("N-transform function was not discovered at init time") return url } Timber.tag(TAG).d("Calling webView.transformN()...") val transformedN = webView.transformN(nValue) Timber.tag(TAG).d("=== N-TRANSFORM SUCCESS ===") Timber.tag(TAG).d("N-param: $nValue -> $transformedN") // Replace n= parameter in URL val transformedUrl = url.replaceFirst( Regex("([?&])n=[^&]+"), "$1n=${Uri.encode(transformedN)}" ) Timber.tag(TAG).d("Transformed URL length: ${transformedUrl.length}") return transformedUrl } private suspend fun getOrCreateWebView(forceRefresh: Boolean): CipherWebView? { Timber.tag(TAG).d("getOrCreateWebView: forceRefresh=$forceRefresh, existing=${cipherWebView != null}") if (!forceRefresh && cipherWebView != null) { Timber.tag(TAG).d("Reusing existing CipherWebView (hash=$currentPlayerHash)") return cipherWebView } // Close existing WebView if any if (cipherWebView != null) { Timber.tag(TAG).d("Closing existing CipherWebView...") closeWebView() } // Fetch player JS Timber.tag(TAG).d("Fetching player JS...") val result = PlayerJsFetcher.getPlayerJs(forceRefresh = forceRefresh) if (result == null) { Timber.tag(TAG).e("Failed to get player JS") return null } val (playerJs, hash) = result Timber.tag(TAG).d("Got player JS: hash=$hash, length=${playerJs.length}") // Run full analysis for logging - pass the known hash from PlayerJsFetcher Timber.tag(TAG).d("Analyzing player JS for cipher functions (knownHash=$hash)...") val analysis = FunctionNameExtractor.analyzePlayerJs(playerJs, knownHash = hash) if (analysis.sigInfo == null) { Timber.tag(TAG).e("Could not extract signature function info from player JS") return null } if (analysis.nFuncInfo == null) { Timber.tag(TAG).w("Could not extract n-function info from player JS (will try brute-force)") } Timber.tag(TAG).d("Creating CipherWebView...") Timber.tag(TAG).d(" sig: ${analysis.sigInfo.name} (constantArg=${analysis.sigInfo.constantArg}, hardcoded=${analysis.sigInfo.isHardcoded})") Timber.tag(TAG).d(" nFunc: ${analysis.nFuncInfo?.name}[${analysis.nFuncInfo?.arrayIndex}] (hardcoded=${analysis.nFuncInfo?.isHardcoded})") // Create WebView val webView = CipherWebView.create( context = appContext, playerJs = playerJs, sigInfo = analysis.sigInfo, nFuncInfo = analysis.nFuncInfo, ) Timber.tag(TAG).d("CipherWebView created successfully") Timber.tag(TAG).d(" nFunctionAvailable: ${webView.nFunctionAvailable}") Timber.tag(TAG).d(" sigFunctionAvailable: ${webView.sigFunctionAvailable}") Timber.tag(TAG).d(" discoveredNFuncName: ${webView.discoveredNFuncName}") cipherWebView = webView currentPlayerHash = hash return webView } private suspend fun closeWebView() { Timber.tag(TAG).d("closeWebView: existing=${cipherWebView != null}") withContext(Dispatchers.Main) { cipherWebView?.close() } cipherWebView = null currentPlayerHash = null Timber.tag(TAG).d("CipherWebView closed and cleared") } private fun parseQueryParams(query: String): Map { val result = mutableMapOf() for (pair in query.split("&")) { val idx = pair.indexOf('=') if (idx > 0) { val key = Uri.decode(pair.substring(0, idx)) val value = Uri.decode(pair.substring(idx + 1)) result[key] = value } } Timber.tag(TAG).v("parseQueryParams: ${result.keys.joinToString()}") return result } /** * Debug method: Get current state information */ fun getDebugInfo(): Map { return mapOf( "hasWebView" to (cipherWebView != null), "playerHash" to currentPlayerHash, "nFunctionAvailable" to cipherWebView?.nFunctionAvailable, "sigFunctionAvailable" to cipherWebView?.sigFunctionAvailable, "discoveredNFuncName" to cipherWebView?.discoveredNFuncName, "usingHardcodedMode" to cipherWebView?.usingHardcodedMode, ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/cipher/CipherWebView.kt ================================================ package com.metrolist.music.utils.cipher import android.content.Context import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException /** * WebView-based cipher executor for YouTube stream URL deobfuscation * * Executes signature decipher and n-transform functions extracted from player.js. * Supports both regex-extracted functions and hardcoded fallback for Q-array obfuscated players. */ class CipherWebView private constructor( context: Context, private val playerJs: String, private val sigInfo: FunctionNameExtractor.SigFunctionInfo?, private val nFuncInfo: FunctionNameExtractor.NFunctionInfo?, private val initContinuation: Continuation, ) { private val webView = WebView(context) private var sigContinuation: Continuation? = null private var nContinuation: Continuation? = null @Volatile var nFunctionAvailable: Boolean = false private set @Volatile var sigFunctionAvailable: Boolean = false private set @Volatile var discoveredNFuncName: String? = null private set @Volatile var usingHardcodedMode: Boolean = false private set init { Timber.tag(TAG).d("Initializing CipherWebView...") Timber.tag(TAG).d(" sigInfo: name=${sigInfo?.name}, constantArg=${sigInfo?.constantArg}, hardcoded=${sigInfo?.isHardcoded}") Timber.tag(TAG).d(" nFuncInfo: name=${nFuncInfo?.name}, arrayIdx=${nFuncInfo?.arrayIndex}, hardcoded=${nFuncInfo?.isHardcoded}") val settings = webView.settings @Suppress("SetJavaScriptEnabled") settings.javaScriptEnabled = true settings.allowFileAccess = true @Suppress("DEPRECATION") settings.allowFileAccessFromFileURLs = true settings.blockNetworkLoads = true webView.addJavascriptInterface(this, JS_INTERFACE) webView.webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(m: ConsoleMessage): Boolean { val msg = m.message() val src = "${m.sourceId()}:${m.lineNumber()}" // Log all console messages for debugging when (m.messageLevel()) { ConsoleMessage.MessageLevel.ERROR -> { if (!msg.contains("is not defined")) { Timber.tag(TAG).e("JS ERROR: $msg at $src") } } ConsoleMessage.MessageLevel.WARNING -> { Timber.tag(TAG).w("JS WARN: $msg at $src") } else -> { Timber.tag(TAG).v("JS LOG: $msg") } } return super.onConsoleMessage(m) } } Timber.tag(TAG).d("WebView settings configured") } private fun loadPlayerJsFromFile() { val sigFuncName = sigInfo?.name val nFuncName = nFuncInfo?.name val nArrayIdx = nFuncInfo?.arrayIndex val isHardcoded = sigInfo?.isHardcoded == true || nFuncInfo?.isHardcoded == true Timber.tag(TAG).d("=== LOADING PLAYER.JS INTO WEBVIEW ===") Timber.tag(TAG).d("Player.js size: ${playerJs.length} chars") Timber.tag(TAG).d("Export mode: ${if (isHardcoded) "HARDCODED" else "EXTRACTED"}") Timber.tag(TAG).d("Sig function: $sigFuncName (constantArg=${sigInfo?.constantArg})") Timber.tag(TAG).d("N function: $nFuncName (arrayIdx=$nArrayIdx)") usingHardcodedMode = isHardcoded val exports = buildList { if (sigFuncName != null) { val sigConstArgs = sigInfo?.constantArgs val preprocessFunc = sigInfo?.preprocessFunc val preprocessArgs = sigInfo?.preprocessArgs if (!sigConstArgs.isNullOrEmpty() && preprocessFunc != null && !preprocessArgs.isNullOrEmpty()) { // Full wrapper: JI(48, 1918, f1(1, 6528, sig)) val mainArgsStr = sigConstArgs.joinToString(", ") val prepArgsStr = preprocessArgs.joinToString(", ") Timber.tag(TAG).d("Sig function needs full wrapper:") Timber.tag(TAG).d(" $sigFuncName($mainArgsStr, $preprocessFunc($prepArgsStr, sig))") add("window._cipherSigFunc = function(sig) { return $sigFuncName($mainArgsStr, $preprocessFunc($prepArgsStr, sig)); };") } else if (!sigConstArgs.isNullOrEmpty()) { // Wrapper with constant args only (no preprocessing) val argsStr = sigConstArgs.joinToString(", ") Timber.tag(TAG).d("Sig function needs wrapper with constant args: $argsStr") add("window._cipherSigFunc = function(sig) { return $sigFuncName($argsStr, sig); };") } else if (isHardcoded) { // For hardcoded mode without full args, we'll inject the function export after player.js loads Timber.tag(TAG).d("Will export sig function $sigFuncName in hardcoded mode (legacy)") add("window._cipherSigFunc = typeof $sigFuncName !== 'undefined' ? $sigFuncName : null;") } else { add("window._cipherSigFunc = typeof $sigFuncName !== 'undefined' ? $sigFuncName : null;") } } if (nFuncName != null) { val nConstArgs = nFuncInfo?.constantArgs if (!nConstArgs.isNullOrEmpty()) { // Generate wrapper function for n-functions that require constant args // e.g. GU(6, 6010, n) -> window._nTransformFunc = function(n) { return GU(6, 6010, n); }; val argsStr = nConstArgs.joinToString(", ") Timber.tag(TAG).d("N-function needs wrapper with constant args: $argsStr") add("window._nTransformFunc = function(n) { return $nFuncName($argsStr, n); };") } else { val nExpr = if (nArrayIdx != null) { "$nFuncName[$nArrayIdx]" } else { nFuncName } add("window._nTransformFunc = typeof $nFuncName !== 'undefined' ? $nExpr : null;") } } } Timber.tag(TAG).d("Export statements: ${exports.size}") exports.forEachIndexed { idx, stmt -> Timber.tag(TAG).v(" Export[$idx]: ${stmt.take(80)}...") } val modifiedJs = if (exports.isNotEmpty()) { val exportCode = "; " + exports.joinToString(" ") val modified = playerJs.replace("})(_yt_player);", "$exportCode })(_yt_player);") if (modified == playerJs) { Timber.tag(TAG).w("Export injection point '})(_yt_player);' not found, appending exports") playerJs + "\n" + exportCode } else { Timber.tag(TAG).d("Exports injected into IIFE closure") modified } } else { Timber.tag(TAG).w("No exports to inject") playerJs } val cacheDir = File(webView.context.cacheDir, "cipher") cacheDir.mkdirs() val playerJsFile = File(cacheDir, "player.js") playerJsFile.writeText(modifiedJs) Timber.tag(TAG).d("Player.js written to cache: ${playerJsFile.absolutePath} (${modifiedJs.length} chars)") // Build HTML with comprehensive discovery and validation val html = buildDiscoveryHtml() Timber.tag(TAG).d("Discovery HTML built (${html.length} chars)") webView.loadDataWithBaseURL( "file://${cacheDir.absolutePath}/", html, "text/html", "utf-8", null ) Timber.tag(TAG).d("WebView loading started...") } /** * Build HTML with JS discovery logic * * Key changes from original: * 1. Removed outdated `_w8_` pattern check * 2. Accept any valid alphanumeric transform result * 3. More comprehensive logging to bridge */ private fun buildDiscoveryHtml(): String = """ """ // ==================== JAVASCRIPT INTERFACE ==================== @JavascriptInterface fun logDebug(message: String) { Timber.tag(TAG).d("JS: $message") } @JavascriptInterface fun onDiscoveryDone(sigFuncName: String, nFuncName: String, info: String) { Timber.tag(TAG).d("=== DISCOVERY COMPLETE ===") Timber.tag(TAG).d("Sig function: ${sigFuncName.ifEmpty { "NOT FOUND" }}") Timber.tag(TAG).d("N function: ${nFuncName.ifEmpty { "NOT FOUND" }}") Timber.tag(TAG).d("Info: $info") sigFunctionAvailable = sigFuncName.isNotEmpty() if (nFuncName.isNotEmpty()) { discoveredNFuncName = nFuncName nFunctionAvailable = true Timber.tag(TAG).d("N-function AVAILABLE: $nFuncName") } else { Timber.tag(TAG).e("N-function NOT AVAILABLE") nFunctionAvailable = false } } @JavascriptInterface fun onNDiscoveryDone(funcName: String, info: String) { // Legacy interface - redirects to new combined discovery Timber.tag(TAG).d("Legacy onNDiscoveryDone: funcName=$funcName, info=$info") if (funcName.isNotEmpty()) { discoveredNFuncName = funcName nFunctionAvailable = true } } @JavascriptInterface fun onPlayerJsLoaded() { Timber.tag(TAG).d("=== PLAYER.JS LOAD COMPLETE ===") Timber.tag(TAG).d("sigFunctionAvailable=$sigFunctionAvailable") Timber.tag(TAG).d("nFunctionAvailable=$nFunctionAvailable") Timber.tag(TAG).d("discoveredNFuncName=$discoveredNFuncName") Timber.tag(TAG).d("usingHardcodedMode=$usingHardcodedMode") initContinuation.resume(this) } @JavascriptInterface fun onPlayerJsError(error: String) { Timber.tag(TAG).e("=== PLAYER.JS LOAD FAILED ===") Timber.tag(TAG).e("Error: $error") initContinuation.resumeWithException(CipherException("Player JS load failed: $error")) } // ==================== SIGNATURE DEOBFUSCATION ==================== suspend fun deobfuscateSignature(obfuscatedSig: String): String { Timber.tag(TAG).d("========== DEOBFUSCATE SIGNATURE ==========") Timber.tag(TAG).d("Input sig length: ${obfuscatedSig.length}") Timber.tag(TAG).d("Input sig preview: ${obfuscatedSig.take(50)}...") Timber.tag(TAG).d("sigInfo: name=${sigInfo?.name}, constantArg=${sigInfo?.constantArg}") if (sigInfo == null) { Timber.tag(TAG).e("Signature function info not available") throw CipherException("Signature function info not available") } return withContext(Dispatchers.Main) { suspendCancellableCoroutine { cont -> sigContinuation = cont val constArgJs = if (sigInfo.constantArg != null) "${sigInfo.constantArg}" else "null" val jsCall = "deobfuscateSig('${sigInfo.name}', $constArgJs, '${escapeJsString(obfuscatedSig)}')" Timber.tag(TAG).d("Evaluating JS: ${jsCall.take(100)}...") webView.evaluateJavascript(jsCall, null) } } } @JavascriptInterface fun onSigResult(result: String) { Timber.tag(TAG).d("========== SIGNATURE RESULT ==========") Timber.tag(TAG).d("Result length: ${result.length}") Timber.tag(TAG).d("Result preview: ${result.take(50)}...") sigContinuation?.resume(result) sigContinuation = null } @JavascriptInterface fun onSigError(error: String) { Timber.tag(TAG).e("========== SIGNATURE ERROR ==========") Timber.tag(TAG).e("Error: $error") sigContinuation?.resumeWithException(CipherException("Sig deobfuscation failed: $error")) sigContinuation = null } // ==================== N-TRANSFORM ==================== suspend fun transformN(nValue: String): String { Timber.tag(TAG).d("========== N-TRANSFORM ==========") Timber.tag(TAG).d("Input n value: $nValue") Timber.tag(TAG).d("nFunctionAvailable: $nFunctionAvailable") Timber.tag(TAG).d("discoveredNFuncName: $discoveredNFuncName") if (!nFunctionAvailable) { Timber.tag(TAG).e("N-transform function not discovered") throw CipherException("N-transform function not discovered") } return withContext(Dispatchers.Main) { suspendCancellableCoroutine { cont -> nContinuation = cont val jsCall = "transformN('${escapeJsString(nValue)}')" Timber.tag(TAG).d("Evaluating JS: $jsCall") webView.evaluateJavascript(jsCall, null) } } } @JavascriptInterface fun onNResult(result: String) { Timber.tag(TAG).d("========== N-TRANSFORM RESULT ==========") Timber.tag(TAG).d("Result: $result") Timber.tag(TAG).d("Result length: ${result.length}") nContinuation?.resume(result) nContinuation = null } @JavascriptInterface fun onNError(error: String) { Timber.tag(TAG).e("========== N-TRANSFORM ERROR ==========") Timber.tag(TAG).e("Error: $error") nContinuation?.resumeWithException(CipherException("N-transform failed: $error")) nContinuation = null } // ==================== CLEANUP ==================== fun close() { Timber.tag(TAG).d("Closing CipherWebView...") webView.clearHistory() webView.clearCache(true) webView.loadUrl("about:blank") webView.onPause() webView.removeAllViews() webView.destroy() Timber.tag(TAG).d("CipherWebView closed") } // ==================== UTILITIES ==================== private fun escapeJsString(s: String): String { return s.replace("\\", "\\\\") .replace("'", "\\'") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t") } companion object { private const val TAG = "Metrolist_CipherWebView" private const val JS_INTERFACE = "CipherBridge" suspend fun create( context: Context, playerJs: String, sigInfo: FunctionNameExtractor.SigFunctionInfo?, nFuncInfo: FunctionNameExtractor.NFunctionInfo? = null, ): CipherWebView { Timber.tag(TAG).d("=== CREATING CIPHER WEBVIEW ===") Timber.tag(TAG).d("playerJs size: ${playerJs.length} chars") Timber.tag(TAG).d("sigInfo: $sigInfo") Timber.tag(TAG).d("nFuncInfo: $nFuncInfo") return withContext(Dispatchers.Main) { suspendCancellableCoroutine { cont -> val wv = CipherWebView(context, playerJs, sigInfo, nFuncInfo, cont) wv.loadPlayerJsFromFile() } } } } } class CipherException(message: String) : Exception(message) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/cipher/FunctionNameExtractor.kt ================================================ package com.metrolist.music.utils.cipher import timber.log.Timber import java.security.MessageDigest /** * Extracts cipher function names from YouTube's player.js * * Handles both legacy patterns and modern Q-array obfuscation (2025+). * Falls back to hardcoded configs for known player.js hashes when regex fails. */ object FunctionNameExtractor { private const val TAG = "Metrolist_CipherFnExtract" // ==================== DATA CLASSES ==================== data class SigFunctionInfo( val name: String, val constantArg: Int?, // The first numeric argument (e.g., 48 in JI(48, sig)) - legacy val constantArgs: List? = null, // All constant args e.g., JI(48, 1918, ...) -> [48, 1918] val preprocessFunc: String? = null, // Preprocessing function e.g., f1 val preprocessArgs: List? = null, // Preprocess args e.g., f1(1, 6528, sig) -> [1, 6528] val isHardcoded: Boolean = false ) data class NFunctionInfo( val name: String, val arrayIndex: Int?, // e.g. FUNC[0] -> index=0 val constantArgs: List? = null, // e.g. GU(6, 6010, n) -> [6, 6010] val isHardcoded: Boolean = false ) /** * Hardcoded player.js configuration for when regex extraction fails * Due to Q-array obfuscation, patterns like `.get("n")` become `Q[T^6001]` */ data class HardcodedPlayerConfig( val sigFuncName: String, val sigConstantArg: Int?, // Legacy single arg val sigConstantArgs: List? = null, // e.g. JI(48, 1918, ...) -> [48, 1918] val sigPreprocessFunc: String? = null, // e.g. f1 val sigPreprocessArgs: List? = null, // e.g. f1(1, 6528, sig) -> [1, 6528] val nFuncName: String, val nArrayIndex: Int?, val nConstantArgs: List?, // e.g. GU(6, 6010, n) -> [6, 6010] val signatureTimestamp: Int ) // ==================== KNOWN PLAYER CONFIGS ==================== /** * Known player.js configurations indexed by hash * * Player hash 74edf1a3 (March 2026): * - Signature: JI(48, 1918, f1(1, 6528, sig)) -> reverse, swap(0, 57%), reverse * - N-transform: GU(6, 6010, n) with 87-element self-referential array */ private val KNOWN_PLAYER_CONFIGS = mapOf( "74edf1a3" to HardcodedPlayerConfig( sigFuncName = "JI", sigConstantArg = 48, // Legacy sigConstantArgs = listOf(48, 1918), // JI(48, 1918, processedSig) sigPreprocessFunc = "f1", // sig must be preprocessed through f1() sigPreprocessArgs = listOf(1, 6528), // f1(1, 6528, sig) nFuncName = "GU", nArrayIndex = null, // Direct function, not array access nConstantArgs = listOf(6, 6010), // GU(6, 6010, n) - the function requires 3 args! signatureTimestamp = 20522 ) ) // ==================== DETECTION PATTERNS ==================== // Detect Q-array obfuscation: var Q="...".split("}") private val Q_ARRAY_PATTERN = Regex("""var\s+Q\s*=\s*"[^"]+"\s*\.\s*split\s*\(\s*"\}"\s*\)""") // Extract player hash from common patterns private val PLAYER_HASH_PATTERNS = listOf( Regex("""jsUrl['":\s]+[^"']*?/player/([a-f0-9]{8})/"""), Regex("""player_ias\.vflset/[^/]+/([a-f0-9]{8})/"""), Regex("""/s/player/([a-f0-9]{8})/""") ) // Modern 2025+ signature deobfuscation function patterns private val SIG_FUNCTION_PATTERNS = listOf( // Pattern 1 (2025+): &&(VAR=FUNC(NUM,decodeURIComponent(VAR)) Regex("""&&\s*\(\s*[a-zA-Z0-9$]+\s*=\s*([a-zA-Z0-9$]+)\s*\(\s*(\d+)\s*,\s*decodeURIComponent\s*\(\s*[a-zA-Z0-9$]+\s*\)"""), // Classic patterns (pre-2025, kept as fallback) Regex("""\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*encodeURIComponent\(([a-zA-Z0-9$]+)\("""), Regex("""\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*encodeURIComponent\(([a-zA-Z0-9$]+)\("""), Regex("""\bm=([a-zA-Z0-9${'$'}]{2,})\(decodeURIComponent\(h\.s\)\)"""), Regex("""\bc\s*&&\s*d\.set\([^,]+\s*,\s*(?:encodeURIComponent\s*\()([a-zA-Z0-9$]+)\("""), Regex("""\bc\s*&&\s*[a-z]\.set\([^,]+\s*,\s*encodeURIComponent\(([a-zA-Z0-9$]+)\("""), ) // N-parameter (throttle) transform function patterns private val N_FUNCTION_PATTERNS = listOf( // Pattern 1: .get("n"))&&(b=FUNC[IDX](VAR) Regex("""\.get\("n"\)\)&&\(b=([a-zA-Z0-9$]+)(?:\[(\d+)\])?\(([a-zA-Z0-9])\)"""), // Pattern 2: .get("n"))&&(FUNC=VAR[IDX](FUNC) (2025+ variant) Regex("""\.get\("n"\)\)\s*&&\s*\(([a-zA-Z0-9$]+)\s*=\s*([a-zA-Z0-9$]+)(?:\[(\d+)\])?\(\1\)"""), // Pattern 3: String.fromCharCode(110) variant (110 = 'n') Regex("""\(\s*([a-zA-Z0-9$]+)\s*=\s*String\.fromCharCode\(110\)"""), // Pattern 4: enhanced_except_ function pattern Regex("""([a-zA-Z0-9$]+)\s*=\s*function\([a-zA-Z0-9]\)\s*\{[^}]*?enhanced_except_"""), ) // ==================== EXTRACTION FUNCTIONS ==================== /** * Detect if player.js uses Q-array obfuscation */ fun hasQArrayObfuscation(playerJs: String): Boolean { val hasQArray = Q_ARRAY_PATTERN.containsMatchIn(playerJs) Timber.tag(TAG).d("Q-array obfuscation check: hasQArray=$hasQArray") if (hasQArray) { // Try to count Q array elements for additional info val match = Q_ARRAY_PATTERN.find(playerJs) if (match != null) { val start = match.range.first val qDefEnd = playerJs.indexOf(";", start) if (qDefEnd > start) { val qDef = playerJs.substring(start, qDefEnd) val elementCount = qDef.count { it == '}' } + 1 Timber.tag(TAG).d("Q-array detected with ~$elementCount elements") } } } return hasQArray } /** * Extract player.js hash from embedded URLs or compute from content */ fun extractPlayerHash(playerJs: String): String? { Timber.tag(TAG).d("Extracting player hash from playerJs (${playerJs.length} chars)") // Try to extract from embedded URLs first for ((index, pattern) in PLAYER_HASH_PATTERNS.withIndex()) { val match = pattern.find(playerJs) if (match != null) { val hash = match.groupValues[1] Timber.tag(TAG).d("Player hash found via pattern $index: $hash") return hash } } // Fallback: compute hash from first 10KB of content val contentToHash = playerJs.take(10000) val md = MessageDigest.getInstance("MD5") val digest = md.digest(contentToHash.toByteArray()) val computedHash = digest.take(4).joinToString("") { "%02x".format(it) } Timber.tag(TAG).d("Player hash computed from content: $computedHash") return computedHash } /** * Get hardcoded config for a known player.js hash */ fun getHardcodedConfig(playerHash: String): HardcodedPlayerConfig? { val config = KNOWN_PLAYER_CONFIGS[playerHash] if (config != null) { Timber.tag(TAG).d("Found hardcoded config for hash $playerHash:") Timber.tag(TAG).d(" sigFunc=${config.sigFuncName}(${config.sigConstantArg}, ...)") Timber.tag(TAG).d(" nFunc=${config.nFuncName}[${config.nArrayIndex}]") Timber.tag(TAG).d(" signatureTimestamp=${config.signatureTimestamp}") } else { Timber.tag(TAG).w("No hardcoded config for hash: $playerHash") Timber.tag(TAG).w("Known hashes: ${KNOWN_PLAYER_CONFIGS.keys.joinToString()}") } return config } /** * Extract signature function info from player.js * * Uses regex patterns first, falls back to hardcoded config if Q-array detected * @param playerJs The player.js content * @param knownHash Optional hash for hardcoded config lookup */ fun extractSigFunctionInfo(playerJs: String, knownHash: String? = null): SigFunctionInfo? { Timber.tag(TAG).d("========== EXTRACTING SIG FUNCTION ==========") Timber.tag(TAG).d("Player.js size: ${playerJs.length} chars") // Try regex patterns first for ((index, pattern) in SIG_FUNCTION_PATTERNS.withIndex()) { Timber.tag(TAG).v("Trying sig pattern $index: ${pattern.pattern.take(60)}...") val match = pattern.find(playerJs) if (match != null) { val name = match.groupValues[1] val constArg = if (match.groupValues.size > 2) match.groupValues[2].toIntOrNull() else null Timber.tag(TAG).d("SIG FUNCTION FOUND via pattern $index:") Timber.tag(TAG).d(" name=$name, constantArg=$constArg") Timber.tag(TAG).d(" match context: ...${playerJs.substring(maxOf(0, match.range.first - 20), minOf(playerJs.length, match.range.last + 20))}...") return SigFunctionInfo(name, constArg, isHardcoded = false) } } Timber.tag(TAG).w("No sig pattern matched, checking for Q-array obfuscation...") // Check for Q-array obfuscation and use hardcoded fallback if (hasQArrayObfuscation(playerJs)) { // Use knownHash if provided, otherwise try to extract val hashToUse = knownHash ?: extractPlayerHash(playerJs) Timber.tag(TAG).d("Using hash for hardcoded lookup: $hashToUse (knownHash=$knownHash)") if (hashToUse != null) { val config = getHardcodedConfig(hashToUse) if (config != null) { Timber.tag(TAG).d("USING HARDCODED SIG FUNCTION: ${config.sigFuncName}(${config.sigConstantArgs}, ...)") Timber.tag(TAG).d("Sig preprocess: ${config.sigPreprocessFunc}(${config.sigPreprocessArgs}, sig)") return SigFunctionInfo( name = config.sigFuncName, constantArg = config.sigConstantArg, constantArgs = config.sigConstantArgs, preprocessFunc = config.sigPreprocessFunc, preprocessArgs = config.sigPreprocessArgs, isHardcoded = true ) } } } Timber.tag(TAG).e("========== SIG FUNCTION EXTRACTION FAILED ==========") Timber.tag(TAG).e("Could not find signature deobfuscation function name") return null } /** * Extract N-transform function info from player.js * * Uses regex patterns first, falls back to hardcoded config if Q-array detected * @param playerJs The player.js content * @param knownHash Optional hash for hardcoded config lookup */ fun extractNFunctionInfo(playerJs: String, knownHash: String? = null): NFunctionInfo? { Timber.tag(TAG).d("========== EXTRACTING N-FUNCTION ==========") Timber.tag(TAG).d("Player.js size: ${playerJs.length} chars") // Try regex patterns first for ((index, pattern) in N_FUNCTION_PATTERNS.withIndex()) { Timber.tag(TAG).v("Trying n-func pattern $index: ${pattern.pattern.take(60)}...") val match = pattern.find(playerJs) if (match != null) { when (index) { 0 -> { val name = match.groupValues[1] val arrayIdx = match.groupValues[2].toIntOrNull() Timber.tag(TAG).d("N-FUNCTION FOUND via pattern $index:") Timber.tag(TAG).d(" name=$name, arrayIndex=$arrayIdx") return NFunctionInfo(name, arrayIdx, isHardcoded = false) } 1 -> { val name = match.groupValues[2] val arrayIdx = match.groupValues[3].toIntOrNull() Timber.tag(TAG).d("N-FUNCTION FOUND via pattern $index:") Timber.tag(TAG).d(" name=$name, arrayIndex=$arrayIdx") return NFunctionInfo(name, arrayIdx, isHardcoded = false) } else -> { val name = match.groupValues[1] Timber.tag(TAG).d("N-FUNCTION FOUND via pattern $index:") Timber.tag(TAG).d(" name=$name") return NFunctionInfo(name, null, isHardcoded = false) } } } } Timber.tag(TAG).w("No n-func pattern matched, checking for Q-array obfuscation...") // Check for Q-array obfuscation and use hardcoded fallback if (hasQArrayObfuscation(playerJs)) { // Use knownHash if provided, otherwise try to extract val hashToUse = knownHash ?: extractPlayerHash(playerJs) Timber.tag(TAG).d("Using hash for hardcoded lookup: $hashToUse (knownHash=$knownHash)") if (hashToUse != null) { val config = getHardcodedConfig(hashToUse) if (config != null) { Timber.tag(TAG).d("USING HARDCODED N-FUNCTION: ${config.nFuncName}[${config.nArrayIndex}]") Timber.tag(TAG).d("N-function constant args: ${config.nConstantArgs}") return NFunctionInfo(config.nFuncName, config.nArrayIndex, config.nConstantArgs, isHardcoded = true) } } } Timber.tag(TAG).e("========== N-FUNCTION EXTRACTION FAILED ==========") Timber.tag(TAG).e("Could not find n-transform function name") return null } /** * Extract signatureTimestamp from player.js */ fun extractSignatureTimestamp(playerJs: String): Int? { Timber.tag(TAG).d("Extracting signatureTimestamp...") val patterns = listOf( Regex("""signatureTimestamp['":\s]+(\d+)"""), Regex("""sts['":\s]+(\d+)"""), Regex(""""signatureTimestamp"\s*:\s*(\d+)""") ) for ((index, pattern) in patterns.withIndex()) { val match = pattern.find(playerJs) if (match != null) { val sts = match.groupValues[1].toIntOrNull() if (sts != null) { Timber.tag(TAG).d("signatureTimestamp found via pattern $index: $sts") return sts } } } // Fallback to hardcoded config val playerHash = extractPlayerHash(playerJs) if (playerHash != null) { val config = getHardcodedConfig(playerHash) if (config != null) { Timber.tag(TAG).d("Using hardcoded signatureTimestamp: ${config.signatureTimestamp}") return config.signatureTimestamp } } Timber.tag(TAG).w("Could not extract signatureTimestamp") return null } /** * Full analysis of player.js - extracts all cipher info * @param playerJs The player.js content * @param knownHash Optional hash from PlayerJsFetcher (preferred over computed) */ fun analyzePlayerJs(playerJs: String, knownHash: String? = null): PlayerAnalysis { Timber.tag(TAG).d("=== PLAYER.JS CIPHER ANALYSIS ===") // Use knownHash from PlayerJsFetcher if provided, otherwise extract/compute val playerHash = if (knownHash != null) { Timber.tag(TAG).d("Using known hash from PlayerJsFetcher: $knownHash") knownHash } else { extractPlayerHash(playerJs) } val hasQArray = hasQArrayObfuscation(playerJs) val sigInfo = extractSigFunctionInfo(playerJs, playerHash) val nFuncInfo = extractNFunctionInfo(playerJs, playerHash) val signatureTimestamp = extractSignatureTimestamp(playerJs) Timber.tag(TAG).d("=== ANALYSIS SUMMARY ===") Timber.tag(TAG).d("Player Hash: ${playerHash ?: "unknown"}") Timber.tag(TAG).d("Q-Array Obfuscated: $hasQArray") Timber.tag(TAG).d("Sig Function: ${sigInfo?.name ?: "NOT FOUND"} (hardcoded=${sigInfo?.isHardcoded})") Timber.tag(TAG).d("Sig Constant Arg: ${sigInfo?.constantArg}") Timber.tag(TAG).d("N-Function: ${nFuncInfo?.name ?: "NOT FOUND"} (hardcoded=${nFuncInfo?.isHardcoded})") Timber.tag(TAG).d("N-Array Index: ${nFuncInfo?.arrayIndex}") Timber.tag(TAG).d("Signature TS: $signatureTimestamp") return PlayerAnalysis( playerHash = playerHash, hasQArrayObfuscation = hasQArray, sigInfo = sigInfo, nFuncInfo = nFuncInfo, signatureTimestamp = signatureTimestamp ) } data class PlayerAnalysis( val playerHash: String?, val hasQArrayObfuscation: Boolean, val sigInfo: SigFunctionInfo?, val nFuncInfo: NFunctionInfo?, val signatureTimestamp: Int? ) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/cipher/PlayerJsFetcher.kt ================================================ package com.metrolist.music.utils.cipher import com.metrolist.innertube.YouTube import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import timber.log.Timber import java.io.File /** * Fetches and caches YouTube's player.js for cipher operations. * * The player.js contains the signature deobfuscation and n-transform functions * that are required to access stream URLs on web clients. */ object PlayerJsFetcher { private const val TAG = "Metrolist_CipherFetcher" private const val IFRAME_API_URL = "https://www.youtube.com/iframe_api" private const val PLAYER_JS_URL_TEMPLATE = "https://www.youtube.com/s/player/%s/player_ias.vflset/en_GB/base.js" private const val CACHE_TTL_MS = 6 * 60 * 60 * 1000L // 6 hours private val httpClient = OkHttpClient.Builder() .proxy(YouTube.proxy) .build() // Regex to extract player hash from iframe_api response private val PLAYER_HASH_REGEX = Regex("""\\?/s\\?/player\\?/([a-zA-Z0-9_-]+)\\?/""") private fun getCacheDir(): File = File(CipherDeobfuscator.appContext.filesDir, "cipher_cache") private fun getCacheFile(hash: String): File = File(getCacheDir(), "player_$hash.js") private fun getHashFile(): File = File(getCacheDir(), "current_hash.txt") /** * Get player.js content and hash. * * Uses cached version if available and not expired, otherwise fetches fresh. * Returns Pair(playerJs, hash) or null if failed. */ suspend fun getPlayerJs(forceRefresh: Boolean = false): Pair? = withContext(Dispatchers.IO) { Timber.tag(TAG).d("=== GET PLAYER.JS ===") Timber.tag(TAG).d("forceRefresh: $forceRefresh") try { val cacheDir = getCacheDir() if (!cacheDir.exists()) { Timber.tag(TAG).d("Creating cache directory: ${cacheDir.absolutePath}") cacheDir.mkdirs() } // Check cache first (unless forced refresh) if (!forceRefresh) { val cached = readFromCache() if (cached != null) { Timber.tag(TAG).d("=== CACHE HIT ===") Timber.tag(TAG).d("Using cached player JS (hash=${cached.second}, length=${cached.first.length})") return@withContext cached } Timber.tag(TAG).d("Cache miss, will fetch fresh") } // Fetch player hash from iframe_api Timber.tag(TAG).d("Fetching player hash from iframe_api...") val hash = fetchPlayerHash() if (hash == null) { Timber.tag(TAG).e("Failed to extract player hash from iframe_api") return@withContext null } Timber.tag(TAG).d("Extracted player hash: $hash") // Download player JS Timber.tag(TAG).d("Downloading player JS for hash: $hash...") val playerJs = downloadPlayerJs(hash) if (playerJs == null) { Timber.tag(TAG).e("Failed to download player JS for hash=$hash") return@withContext null } Timber.tag(TAG).d("=== PLAYER.JS DOWNLOADED ===") Timber.tag(TAG).d("hash: $hash") Timber.tag(TAG).d("length: ${playerJs.length} chars") Timber.tag(TAG).d("preview: ${playerJs.take(100)}...") // Cache the result writeToCache(hash, playerJs) Pair(playerJs, hash) } catch (e: Exception) { Timber.tag(TAG).e(e, "getPlayerJs exception: ${e.message}") null } } /** * Invalidate the player.js cache. * Call this when cipher operations fail to force a fresh fetch. */ fun invalidateCache() { Timber.tag(TAG).d("Invalidating cache...") try { val cacheDir = getCacheDir() if (cacheDir.exists()) { val files = cacheDir.listFiles() Timber.tag(TAG).d("Deleting ${files?.size ?: 0} cache files") files?.forEach { Timber.tag(TAG).v("Deleting: ${it.name}") it.delete() } } Timber.tag(TAG).d("Cache invalidated successfully") } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to invalidate cache: ${e.message}") } } private fun readFromCache(): Pair? { Timber.tag(TAG).d("Checking cache...") try { val hashFile = getHashFile() if (!hashFile.exists()) { Timber.tag(TAG).d("Hash file does not exist") return null } val hashData = hashFile.readText().split("\n") if (hashData.size < 2) { Timber.tag(TAG).d("Hash file malformed (expected 2 lines, got ${hashData.size})") return null } val hash = hashData[0] val timestamp = hashData[1].toLongOrNull() if (timestamp == null) { Timber.tag(TAG).d("Could not parse timestamp from hash file") return null } val ageMs = System.currentTimeMillis() - timestamp val ageHours = ageMs / (1000 * 60 * 60) Timber.tag(TAG).d("Cache age: ${ageHours}h (TTL: ${CACHE_TTL_MS / (1000 * 60 * 60)}h)") // Check TTL if (ageMs > CACHE_TTL_MS) { Timber.tag(TAG).d("Cache expired (hash=$hash, age=${ageHours}h)") return null } val cacheFile = getCacheFile(hash) if (!cacheFile.exists()) { Timber.tag(TAG).d("Cache file does not exist for hash: $hash") return null } val playerJs = cacheFile.readText() if (playerJs.isEmpty()) { Timber.tag(TAG).d("Cache file is empty") return null } Timber.tag(TAG).d("Cache valid: hash=$hash, length=${playerJs.length}, age=${ageHours}h") return Pair(playerJs, hash) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error reading cache: ${e.message}") return null } } private fun writeToCache(hash: String, playerJs: String) { Timber.tag(TAG).d("Writing to cache: hash=$hash, length=${playerJs.length}") try { val cacheDir = getCacheDir() // Clean old cache files val oldFiles = cacheDir.listFiles()?.filter { it.name.startsWith("player_") } Timber.tag(TAG).d("Cleaning ${oldFiles?.size ?: 0} old cache files") oldFiles?.forEach { it.delete() } getCacheFile(hash).writeText(playerJs) getHashFile().writeText("$hash\n${System.currentTimeMillis()}") Timber.tag(TAG).d("Cache written successfully") } catch (e: Exception) { Timber.tag(TAG).e(e, "Error writing cache: ${e.message}") } } private fun fetchPlayerHash(): String? { Timber.tag(TAG).d("Fetching iframe_api from: $IFRAME_API_URL") val request = Request.Builder() .url(IFRAME_API_URL) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .build() val response = httpClient.newCall(request).execute() Timber.tag(TAG).d("iframe_api response: HTTP ${response.code}") if (!response.isSuccessful) { Timber.tag(TAG).e("iframe_api HTTP ${response.code}") return null } val body = response.body?.string() if (body == null) { Timber.tag(TAG).e("iframe_api response body is null") return null } Timber.tag(TAG).d("iframe_api body length: ${body.length}") Timber.tag(TAG).v("iframe_api body preview: ${body.take(200)}...") val match = PLAYER_HASH_REGEX.find(body) if (match == null) { Timber.tag(TAG).e("Could not find player hash in iframe_api response") Timber.tag(TAG).d("Regex pattern: ${PLAYER_HASH_REGEX.pattern}") return null } val hash = match.groupValues[1] Timber.tag(TAG).d("Found player hash: $hash") return hash } private fun downloadPlayerJs(hash: String): String? { val url = PLAYER_JS_URL_TEMPLATE.format(hash) Timber.tag(TAG).d("Downloading player.js from: $url") val request = Request.Builder() .url(url) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .build() val response = httpClient.newCall(request).execute() Timber.tag(TAG).d("player.js response: HTTP ${response.code}") if (!response.isSuccessful) { Timber.tag(TAG).e("player.js download HTTP ${response.code}") return null } val body = response.body?.string() if (body == null) { Timber.tag(TAG).e("player.js response body is null") return null } Timber.tag(TAG).d("player.js downloaded: ${body.length} chars") return body } /** * Debug method: Get cache information */ fun getCacheInfo(): Map { return try { val hashFile = getHashFile() if (!hashFile.exists()) { return mapOf("exists" to false) } val hashData = hashFile.readText().split("\n") val hash = hashData.getOrNull(0) val timestamp = hashData.getOrNull(1)?.toLongOrNull() val cacheFile = hash?.let { getCacheFile(it) } mapOf( "exists" to true, "hash" to hash, "timestamp" to timestamp, "ageMs" to (timestamp?.let { System.currentTimeMillis() - it }), "fileExists" to (cacheFile?.exists() == true), "fileSize" to (cacheFile?.length()), ) } catch (e: Exception) { mapOf("error" to e.message) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/potoken/JavaScriptUtil.kt ================================================ package com.metrolist.music.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[0].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 a [Long] 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() } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/potoken/PoTokenException.kt ================================================ package com.metrolist.music.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) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/potoken/PoTokenGenerator.kt ================================================ package com.metrolist.music.utils.potoken import android.webkit.CookieManager import com.metrolist.music.utils.cipher.CipherDeobfuscator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import timber.log.Timber 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? { Timber.tag(TAG).d("getWebClientPoToken called: videoId=$videoId, sessionId=$sessionId") Timber.tag(TAG).d("WebView state: supported=$webViewSupported, badImpl=$webViewBadImpl") if (!webViewSupported || webViewBadImpl) { Timber.tag(TAG).d("WebView not available: supported=$webViewSupported, badImpl=$webViewBadImpl") return null } return try { Timber.tag(TAG).d("Calling runBlocking to generate poToken...") runBlocking { getWebClientPoToken(videoId, sessionId, forceRecreate = false) } } catch (e: Exception) { Timber.tag(TAG).e(e, "poToken generation exception: ${e.javaClass.simpleName}: ${e.message}") when (e) { is BadWebViewException -> { Timber.tag(TAG).e(e, "Could not obtain poToken because WebView is broken") 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 { Timber.tag(TAG).d("Web poToken requested: videoId=$videoId, sessionId=$sessionId") val (poTokenGenerator, streamingPot, hasBeenRecreated) = webPoTokenGenLock.withLock { val shouldRecreate = forceRecreate || webPoTokenGenerator == null || webPoTokenGenerator!!.isExpired || webPoTokenSessionId != sessionId if (shouldRecreate) { Timber.tag(TAG).d("Creating new PoTokenWebView (forceRecreate=$forceRecreate)") webPoTokenSessionId = sessionId withContext(Dispatchers.Main) { webPoTokenGenerator?.close() } // create a new webPoTokenGenerator webPoTokenGenerator = PoTokenWebView.getNewPoTokenGenerator(CipherDeobfuscator.appContext) // The streaming poToken needs to be generated exactly once before generating // any other (player) tokens. webPoTokenStreamingPot = webPoTokenGenerator!!.generatePoToken(webPoTokenSessionId!!) Timber.tag(TAG).d("Streaming poToken generated for sessionId=${webPoTokenSessionId?.take(20)}...") } Triple(webPoTokenGenerator!!, webPoTokenStreamingPot!!, shouldRecreate) } val playerPot = try { 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 Timber.tag(TAG).e(throwable, "Failed to obtain poToken, retrying") return getWebClientPoToken(videoId = videoId, sessionId = sessionId, forceRecreate = true) } } Timber.tag(TAG).d("poToken generated successfully: player=${playerPot.take(20)}..., streaming=${streamingPot.take(20)}...") return PoTokenResult(playerPot, streamingPot) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/potoken/PoTokenResult.kt ================================================ package com.metrolist.music.utils.potoken class PoTokenResult( val playerRequestPoToken: String, val streamingDataPoToken: String, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/potoken/PoTokenWebView.kt ================================================ package com.metrolist.music.utils.potoken import android.content.Context 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.metrolist.innertube.YouTube import com.metrolist.music.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 timber.log.Timber 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.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 { val msg = m.message() // Log all console messages for debugging when (m.messageLevel()) { ConsoleMessage.MessageLevel.ERROR -> Timber.tag(TAG).e("JS: $msg") ConsoleMessage.MessageLevel.WARNING -> Timber.tag(TAG).w("JS: $msg") else -> Timber.tag(TAG).d("JS: $msg") } if (msg.contains("Uncaught")) { val fmt = "\"$msg\", source: ${m.sourceId()} (${m.lineNumber()})" val exception = BadWebViewException(fmt) Timber.tag(TAG).e("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() { Timber.tag(TAG).d("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() { Timber.tag(TAG).d("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) { Timber.tag(TAG).e("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) { Timber.tag(TAG).d("botguardResponse: $botguardResponse") makeBotguardServiceRequest( "https://www.youtube.com/api/jnn/v1/GenerateIT", "[ \"$REQUEST_KEY\", \"$botguardResponse\" ]", ) { responseBody -> Timber.tag(TAG).d("GenerateIT response: $responseBody") try { val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody) Timber.tag(TAG).d("Parsed integrityToken (${integrityToken.take(50)}...), expires in $expirationTimeInSeconds sec") // leave 10 minutes of margin just to be sure expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds).minus(10, ChronoUnit.MINUTES) // Store integrityToken and create the minter callback ONCE // NOTE: createPoTokenMinter is now async, so we use .then() Timber.tag(TAG).d("Evaluating createPoTokenMinter JavaScript...") webView.evaluateJavascript( """try { console.log('[JS] Setting integrityToken and calling createPoTokenMinter...'); this.integrityToken = $integrityToken console.log('[JS] integrityToken set, now calling createPoTokenMinter...'); createPoTokenMinter(webPoSignalOutput, integrityToken).then(function() { console.log('[JS] createPoTokenMinter .then() resolved!'); $JS_INTERFACE.onMinterCreated() }).catch(function(error) { console.log('[JS] createPoTokenMinter .catch() error: ' + error); $JS_INTERFACE.onJsInitializationError(error + "\n" + (error.stack || '')) }) } catch (error) { console.log('[JS] createPoTokenMinter SYNC error: ' + error); $JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack) }""", null ) } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to parse integrity token data: ${e.message}") onInitializationErrorCloseAndCancel(PoTokenException("parseIntegrityTokenData failed: ${e.message}")) } } } /** * Called during initialization after the poToken minter has been created successfully. */ @JavascriptInterface fun onMinterCreated() { Timber.tag(TAG).d("poToken minter created successfully, initialization complete") continuation.resume(this) } //endregion //region Obtaining poTokens suspend fun generatePoToken(identifier: String): String { return withContext(Dispatchers.Main) { suspendCancellableCoroutine { cont -> Timber.tag(TAG).d("generatePoToken() called with identifier $identifier") addPoTokenEmitter(identifier, cont) // NOTE: obtainPoToken is now async, so we use .then() webView.evaluateJavascript( """try { identifier = "$identifier" u8Identifier = ${stringToU8(identifier)} obtainPoToken(u8Identifier).then(function(poTokenU8) { poTokenU8String = poTokenU8.join(",") $JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String) }).catch(function(error) { $JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + (error.stack || '')) }) } 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) { Timber.tag(TAG).e("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) { Timber.tag(TAG).d("Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8") val poToken = try { u8ToBase64(poTokenU8) } catch (t: Throwable) { popPoTokenContinuation(identifier)?.resumeWithException(t) return } Timber.tag(TAG).d("Generated poToken: identifier=$identifier poToken=$poToken") popPoTokenContinuation(identifier)?.resume(poToken) } val isExpired: Boolean get() = Instant.now().isAfter(expirationInstant) //endregion //region Handling multiple emitters private fun addPoTokenEmitter(identifier: String, continuation: Continuation) { poTokenContinuations[identifier] = continuation } private fun popPoTokenContinuation(identifier: String): Continuation? { return poTokenContinuations.remove(identifier) } private fun popAllPoTokenContinuations(): Map> { val result = poTokenContinuations.toMap() poTokenContinuations.clear() return result } //endregion //region Utils 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) } } } private fun onInitializationErrorCloseAndCancel(error: Throwable) { close() continuation.resumeWithException(error) } @MainThread fun close() { scope.cancel() webView.clearHistory() webView.clearCache(true) webView.loadUrl("about:blank") webView.onPause() webView.removeAllViews() webView.destroy() } //endregion companion object { private const val TAG = "PoTokenWebView" private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw" 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() } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/sabr/EjsNTransformSolver.kt ================================================ package com.metrolist.music.utils.sabr import android.content.Context import android.net.Uri import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebView import com.metrolist.music.utils.cipher.CipherDeobfuscator import com.metrolist.music.utils.cipher.PlayerJsFetcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import kotlin.coroutines.Continuation import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException /** * Standalone EJS-based n-parameter transform solver for SABR URLs. * * Uses the same AST-based approach as yt-dlp's EJS solver (meriyah + astring + * yt.solver.core.js) to reliably extract and execute the n-transform function * from the YouTube player JS. */ object EjsNTransformSolver { private const val TAG = "Metrolist_EjsNSolver" private var solverWebView: SolverWebView? = null /** * Transform the 'n' parameter in a SABR streaming URL. * Returns the URL with the transformed 'n' value, or the original URL if transform fails. */ suspend fun transformNParamInUrl(url: String): String { val nMatch = Regex("[?&]n=([^&]+)").find(url) if (nMatch == null) { Timber.tag(TAG).d("No 'n' parameter in SABR URL") return url } val nValue = Uri.decode(nMatch.groupValues[1]) Timber.tag(TAG).d("SABR n-param: $nValue") return withContext(NonCancellable) { val solver = getOrCreateSolver() if (solver == null) { return@withContext url } if (!solver.nFunctionAvailable) { Timber.tag(TAG).e("EJS n-solver not available") return@withContext url } try { val transformed = solver.transformN(nValue) Timber.tag(TAG).d("SABR n-param transformed: $nValue -> $transformed") url.replaceFirst( Regex("([?&])n=[^&]+"), "$1n=${Uri.encode(transformed)}" ) } catch (e: Exception) { Timber.tag(TAG).e(e, "SABR n-transform failed: ${e.message}") url } } } private suspend fun getOrCreateSolver(): SolverWebView? { solverWebView?.let { return it } return withContext(NonCancellable) { solverWebView?.let { return@withContext it } val result = PlayerJsFetcher.getPlayerJs(forceRefresh = false) if (result == null) { Timber.tag(TAG).e("Failed to get player JS for EJS solver") return@withContext null } val (playerJs, hash) = result Timber.tag(TAG).d("Creating EJS n-solver (player hash=$hash, ${playerJs.length} chars)") try { val sv = SolverWebView.create(CipherDeobfuscator.appContext, playerJs) solverWebView = sv sv } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to create EJS solver: ${e.message}") null } } } suspend fun close() { withContext(Dispatchers.Main) { solverWebView?.close() } solverWebView = null } class SolverWebView private constructor( context: Context, private val playerJs: String, private val initContinuation: Continuation, ) { private val webView = WebView(context) private var nContinuation: Continuation? = null @Volatile var nFunctionAvailable: Boolean = false private set init { val settings = webView.settings @Suppress("SetJavaScriptEnabled") settings.javaScriptEnabled = true settings.allowFileAccess = true @Suppress("DEPRECATION") settings.allowFileAccessFromFileURLs = true settings.blockNetworkLoads = true webView.addJavascriptInterface(this, "EjsBridge") webView.webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(m: ConsoleMessage): Boolean { val msg = m.message() if (msg.contains("Uncaught")) { Timber.tag(TAG).e("EJS WebView error: $msg at ${m.sourceId()}:${m.lineNumber()}") } return super.onConsoleMessage(m) } } } private fun loadSolverAndPlayer() { val cacheDir = File(webView.context.cacheDir, "ejs_solver") cacheDir.mkdirs() val assetManager = webView.context.assets for (file in listOf("meriyah.js", "astring.js", "yt.solver.core.js")) { assetManager.open("solver/$file").use { input -> File(cacheDir, file).outputStream().use { output -> input.copyTo(output) } } } File(cacheDir, "player.js").writeText(playerJs) Timber.tag(TAG).d("EJS solver assets written (${playerJs.length} chars)") val html = """ """ webView.loadDataWithBaseURL( "file://${cacheDir.absolutePath}/", html, "text/html", "utf-8", null ) } @JavascriptInterface fun onLog(message: String) { Timber.tag(TAG).d(message) } @JavascriptInterface fun onSolverReady(nAvailable: String) { nFunctionAvailable = nAvailable == "true" Timber.tag(TAG).d("EJS solver ready: n=$nFunctionAvailable") initContinuation.resume(this) } @JavascriptInterface fun onSolverError(error: String) { Timber.tag(TAG).e("EJS solver error: $error") initContinuation.resume(this) } suspend fun transformN(nValue: String): String { if (!nFunctionAvailable) { throw SabrException("EJS n-transform not available") } return withContext(Dispatchers.Main) { suspendCancellableCoroutine { cont -> nContinuation = cont webView.evaluateJavascript( "transformN('${escapeJsString(nValue)}')", null ) } } } @JavascriptInterface fun onNResult(result: String) { Timber.tag(TAG).d("N-transform result: ${result.take(50)}") nContinuation?.resume(result) nContinuation = null } @JavascriptInterface fun onNError(error: String) { Timber.tag(TAG).e("N-transform error: $error") nContinuation?.resumeWithException(SabrException("EJS n-transform failed: $error")) nContinuation = null } fun close() { webView.clearHistory() webView.clearCache(true) webView.loadUrl("about:blank") webView.onPause() webView.removeAllViews() webView.destroy() Timber.tag(TAG).d("EJS solver WebView closed") } private fun escapeJsString(s: String): String { return s.replace("\\", "\\\\") .replace("'", "\\'") .replace("\n", "\\n") .replace("\r", "\\r") } companion object { suspend fun create(context: Context, playerJs: String): SolverWebView { return withContext(Dispatchers.Main) { suspendCancellableCoroutine { cont -> val sv = SolverWebView(context, playerJs, cont) sv.loadSolverAndPlayer() } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/utils/sabr/SabrException.kt ================================================ package com.metrolist.music.utils.sabr class SabrException(message: String, cause: Throwable? = null) : Exception(message, cause) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/AccountSettingsViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.music.App import com.metrolist.music.constants.AccountChannelHandleKey import com.metrolist.music.constants.AccountEmailKey import com.metrolist.music.constants.AccountNameKey import com.metrolist.music.constants.DataSyncIdKey import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.VisitorDataKey import com.metrolist.music.utils.SyncUtils import com.metrolist.music.utils.dataStore import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject import androidx.datastore.preferences.core.edit @HiltViewModel class AccountSettingsViewModel @Inject constructor( private val syncUtils: SyncUtils, ) : ViewModel() { /** * Logout user and clear all synced content to prevent data mixing between accounts */ fun logoutAndClearSyncedContent(context: Context, onCookieChange: (String) -> Unit) { viewModelScope.launch(Dispatchers.IO) { // Clear all YouTube Music synced content first syncUtils.clearAllSyncedContent() // Then clear account preferences App.forgetAccount(context) // Clear cookie in UI onCookieChange("") } } /** * Clear all library data including songs, albums, artists, playlists, podcasts. */ suspend fun clearAllLibraryData() { Timber.d("[LOGOUT_CLEAR] ViewModel: clearAllLibraryData called") syncUtils.clearAllLibraryData() Timber.d("[LOGOUT_CLEAR] ViewModel: clearAllLibraryData completed") } /** * Just logout without clearing library data */ suspend fun logoutKeepData(context: Context, onCookieChange: (String) -> Unit) { Timber.d("[LOGOUT_KEEP] ViewModel: logoutKeepData called") withContext(Dispatchers.IO) { App.forgetAccount(context) } Timber.d("[LOGOUT_KEEP] ViewModel: Account forgotten, clearing cookie in UI") onCookieChange("") } /** * Save token credentials atomically to DataStore, then restart the app. * This ensures all writes complete before the process is killed, * preventing the race condition where Runtime.exit(0) kills the process * before async DataStore coroutines finish writing. */ fun saveTokenAndRestart( context: Context, cookie: String, visitorData: String, dataSyncId: String, accountName: String, accountEmail: String, accountChannelHandle: String, ) { viewModelScope.launch(Dispatchers.IO) { context.dataStore.edit { settings -> settings[InnerTubeCookieKey] = cookie settings[VisitorDataKey] = visitorData settings[DataSyncIdKey] = dataSyncId settings[AccountNameKey] = accountName settings[AccountEmailKey] = accountEmail settings[AccountChannelHandleKey] = accountChannelHandle } withContext(Dispatchers.Main) { val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) intent?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) context.startActivity(intent) Runtime.getRuntime().exit(0) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/AccountViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.filterYoutubeShorts import com.metrolist.innertube.utils.completed import com.metrolist.music.constants.HideYoutubeShortsKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.PodcastEntity import com.metrolist.music.ui.utils.resize import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.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.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject enum class AccountContentType { PLAYLISTS, ALBUMS, ARTISTS, PODCASTS } @HiltViewModel class AccountViewModel @Inject constructor( @ApplicationContext private val context: Context, database: MusicDatabase, ) : ViewModel() { val playlists = MutableStateFlow?>(null) val albums = MutableStateFlow?>(null) val artists = MutableStateFlow?>(null) // SE "Episodes for Later" playlist shown in Podcasts tab val sePlaylist = MutableStateFlow(null) // RDPN "New Episodes" playlist (real thumbnail + count from YouTube) val rdpnPlaylist = MutableStateFlow(null) // Subscribed podcast shows (from local DB, synced from YT Music) val podcastPlaylists = database.subscribedPodcasts() .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) // Podcast host channels from YT Music library val podcastChannels = MutableStateFlow>(emptyList()) // Selected content type for chips val selectedContentType = MutableStateFlow(AccountContentType.PLAYLISTS) private suspend fun loadPlaylists() { val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) YouTube.library("FEmusic_liked_playlists").completed().onSuccess { val all = it.items.filterIsInstance() // Extract SE playlist separately for Podcasts tab sePlaylist.value = all.find { it.id == "SE" } playlists.value = all .filterNot { it.id == "SE" } .filterYoutubeShorts(hideYoutubeShorts) }.onFailure { reportException(it) } } init { viewModelScope.launch { loadPlaylists() YouTube.library("FEmusic_liked_albums").completed().onSuccess { albums.value = it.items.filterIsInstance() }.onFailure { reportException(it) } YouTube.library("FEmusic_library_corpus_artists").completed().onSuccess { artists.value = it.items.filterIsInstance().map { artist -> artist.copy( thumbnail = artist.thumbnail?.resize(544, 544) ) } }.onFailure { reportException(it) } } viewModelScope.launch { YouTube.newEpisodesPlaylistInfo().onSuccess { rdpnPlaylist.value = it }.onFailure { reportException(it) } } viewModelScope.launch(Dispatchers.IO) { YouTube.libraryPodcastChannels().onSuccess { podcastChannels.value = it.items.filterIsInstance() }.onFailure { reportException(it) } } // Listen for HideYoutubeShorts preference changes and reload playlists instantly viewModelScope.launch(Dispatchers.IO) { context.dataStore.data .map { it[HideYoutubeShortsKey] ?: false } .distinctUntilChanged() .collect { if (playlists.value != null) { loadPlaylists() } } } } fun setSelectedContentType(contentType: AccountContentType) { selectedContentType.value = contentType } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/AlbumViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.AlbumItem import com.metrolist.music.db.MusicDatabase import com.metrolist.music.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) } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/ArtistAlbumsViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.music.db.MusicDatabase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class ArtistAlbumsViewModel @Inject constructor( database: MusicDatabase, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val artistId = savedStateHandle.get("artistId")!! val artist = database.artist(artistId) .stateIn(viewModelScope, SharingStarted.Lazily, null) val albums = database.artistAlbumsPreview(artistId) .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/ArtistItemsViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.BrowseEndpoint import com.metrolist.innertube.models.filterExplicit import com.metrolist.innertube.models.filterVideoSongs import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.models.ItemsPage import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.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 -> val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) title.value = artistItemsPage.title itemsPage.value = ItemsPage( items = artistItemsPage.items .distinctBy { it.id } .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs), 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 -> val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) itemsPage.update { ItemsPage( items = (oldItemsPage.items + artistItemsContinuationPage.items) .distinctBy { it.id } .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs), continuation = artistItemsContinuationPage.continuation, ) } }.onFailure { reportException(it) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/ArtistViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context 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.metrolist.innertube.YouTube import com.metrolist.innertube.models.filterExplicit import com.metrolist.innertube.models.filterVideoSongs import com.metrolist.innertube.models.filterYoutubeShorts import com.metrolist.innertube.pages.ArtistPage import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.HideYoutubeShortsKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.ArtistEntity import com.metrolist.music.extensions.filterExplicit import com.metrolist.music.extensions.filterExplicitAlbums import com.metrolist.music.utils.SyncUtils import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.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.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject import com.metrolist.music.extensions.filterVideoSongs as filterVideoSongsLocal @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class ArtistViewModel @Inject constructor( @ApplicationContext private val context: Context, private val database: MusicDatabase, private val syncUtils: SyncUtils, savedStateHandle: SavedStateHandle, ) : ViewModel() { val artistId = savedStateHandle.get("artistId")!! private val isPodcastChannel = savedStateHandle.get("isPodcastChannel") ?: false var artistPage by mutableStateOf(null) // Track API subscription state separately private val _apiSubscribed = MutableStateFlow(null) val libraryArtist = database.artist(artistId) .stateIn(viewModelScope, SharingStarted.Lazily, null) // Combine API state with local database state - local takes precedence when not logged in val isChannelSubscribed = kotlinx.coroutines.flow.combine( _apiSubscribed, database.artist(artistId), ) { apiState, localArtist -> val locallyBookmarked = localArtist?.artist?.bookmarkedAt != null locallyBookmarked || (apiState == true) }.stateIn(viewModelScope, SharingStarted.Eagerly, false) val librarySongs = context.dataStore.data .map { (it[HideExplicitKey] ?: false) to (it[HideVideoSongsKey] ?: false) } .distinctUntilChanged() .flatMapLatest { (hideExplicit, hideVideoSongs) -> database.artistSongsPreview(artistId).map { it.filterExplicit(hideExplicit).filterVideoSongsLocal(hideVideoSongs) } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val libraryAlbums = context.dataStore.data .map { it[HideExplicitKey] ?: false } .distinctUntilChanged() .flatMapLatest { hideExplicit -> database.artistAlbumsPreview(artistId).map { it.filterExplicitAlbums(hideExplicit) } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) init { // Load artist page and reload when hide explicit setting changes viewModelScope.launch { context.dataStore.data .map { Triple( it[HideExplicitKey] ?: false, it[HideVideoSongsKey] ?: false, it[HideYoutubeShortsKey] ?: false ) } .distinctUntilChanged() .collect { fetchArtistsFromYTM() } } } fun fetchArtistsFromYTM() { viewModelScope.launch { val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) YouTube.artist(artistId) .onSuccess { page -> val filteredSections = page.sections .map { section -> section.copy(items = section.items.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs).filterYoutubeShorts(hideYoutubeShorts)) } .filter { section -> section.items.isNotEmpty() } artistPage = page.copy(sections = filteredSections) // Store API subscription state _apiSubscribed.value = page.isSubscribed }.onFailure { reportException(it) } } } fun toggleChannelSubscription() { val channelId = artistPage?.artist?.channelId ?: artistId val isCurrentlySubscribed = isChannelSubscribed.value val shouldBeSubscribed = !isCurrentlySubscribed Timber.d("[CHANNEL_TOGGLE] toggleChannelSubscription called: artistId=$artistId, channelId=$channelId, isCurrentlySubscribed=$isCurrentlySubscribed, shouldBeSubscribed=$shouldBeSubscribed") // Optimistically update API state for immediate UI feedback _apiSubscribed.value = shouldBeSubscribed viewModelScope.launch(Dispatchers.IO) { Timber.d("[CHANNEL_TOGGLE] Inside coroutine, updating database...") // Update local database first (optimistic update) // Call DAO methods directly - they're synchronous on IO dispatcher val artist = libraryArtist.value?.artist Timber.d("[CHANNEL_TOGGLE] libraryArtist.value?.artist = $artist") if (artist != null) { val newBookmark = if (shouldBeSubscribed) { artist.bookmarkedAt ?: java.time.LocalDateTime.now() } else { null } // Also set isPodcastChannel if subscribing from podcast context val updatedArtist = artist.copy( bookmarkedAt = newBookmark, isPodcastChannel = if (shouldBeSubscribed && isPodcastChannel) true else artist.isPodcastChannel ) Timber.d("[CHANNEL_TOGGLE] Updating existing artist: ${artist.id} -> bookmarkedAt=$newBookmark, isPodcastChannel=${updatedArtist.isPodcastChannel}") database.update(updatedArtist) } else if (shouldBeSubscribed) { Timber.d("[CHANNEL_TOGGLE] No existing artist, inserting new one") artistPage?.artist?.let { database.insert( ArtistEntity( id = artistId, name = it.title, channelId = it.channelId, thumbnailUrl = it.thumbnail, bookmarkedAt = java.time.LocalDateTime.now(), isPodcastChannel = isPodcastChannel, ) ) Timber.d("[CHANNEL_TOGGLE] Inserted new artist: $artistId, isPodcastChannel=$isPodcastChannel") } ?: Timber.d("[CHANNEL_TOGGLE] artistPage?.artist is null, cannot insert") } else { Timber.d("[CHANNEL_TOGGLE] No artist and shouldBeSubscribed=false, nothing to do") } Timber.d("[CHANNEL_TOGGLE] Calling syncUtils.subscribeChannel($channelId, $shouldBeSubscribed)") // Sync with YouTube (handles login check internally) syncUtils.subscribeChannel(channelId, shouldBeSubscribed) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/AutoPlaylistViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.SongSortDescendingKey import com.metrolist.music.constants.SongSortType import com.metrolist.music.constants.SongSortTypeKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.extensions.filterExplicit import com.metrolist.music.extensions.filterVideoSongs import com.metrolist.music.extensions.toEnum import com.metrolist.music.utils.SyncUtils import com.metrolist.music.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.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest 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, private val database: MusicDatabase, savedStateHandle: SavedStateHandle, private val syncUtils: SyncUtils, ) : ViewModel() { val playlist = savedStateHandle.get("playlist")!! private val _isRefreshing = MutableStateFlow(false) val isRefreshing = _isRefreshing.asStateFlow() @OptIn(ExperimentalCoroutinesApi::class) val likedSongs = context.dataStore.data .map { Triple( it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true), it[HideExplicitKey] ?: false, it[HideVideoSongsKey] ?: false ) } .distinctUntilChanged() .flatMapLatest { (sortDesc, hideExplicit, hideVideoSongs) -> val (sortType, descending) = sortDesc when (playlist) { "liked" -> database.likedSongs(sortType, descending) .map { it.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs) } "downloaded" -> database.downloadedSongs(sortType, descending) .map { it.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs) } "uploaded" -> database.uploadedSongs(sortType, descending) .map { it.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs) } else -> kotlinx.coroutines.flow.flowOf(emptyList()) } } .stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.Lazily, emptyList()) fun syncLikedSongs() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedSongs() } } fun syncUploadedSongs() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncUploadedSongs() } } fun refresh() { viewModelScope.launch(Dispatchers.IO) { _isRefreshing.value = true when (playlist) { "liked" -> syncUtils.syncLikedSongsSuspend() "uploaded" -> syncUtils.syncUploadedSongsSuspend() } _isRefreshing.value = false } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/BackupRestoreViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import android.content.Intent import android.net.Uri import android.widget.Toast import androidx.datastore.preferences.core.edit import androidx.lifecycle.ViewModel import com.metrolist.innertube.utils.parseCookieString import com.metrolist.innertube.utils.sha1 import com.metrolist.music.R import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import com.metrolist.music.constants.DataSyncIdKey import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.VisitorDataKey import com.metrolist.music.db.InternalDatabase import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.ArtistEntity import com.metrolist.music.db.entities.Song import com.metrolist.music.db.entities.SongEntity import com.metrolist.music.extensions.div import com.metrolist.music.extensions.tryOrNull import com.metrolist.music.extensions.zipInputStream import com.metrolist.music.extensions.zipOutputStream import com.metrolist.music.playback.MusicService import com.metrolist.music.playback.MusicService.Companion.PERSISTENT_QUEUE_FILE import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import timber.log.Timber import java.io.FileInputStream import java.io.FileOutputStream import java.util.zip.ZipEntry import javax.inject.Inject data class BackupPreviewInfo( val hasAuthData: Boolean = false, val accountName: String? = null, val accountEmail: String? = null, val accountImageUrl: String? = null, val cookie: String? = null, ) data class CsvImportState( val previewRows: List> = emptyList(), val artistColumnIndex: Int = 0, val titleColumnIndex: Int = 1, val urlColumnIndex: Int = -1, val hasHeader: Boolean = true, ) data class ConvertedSongLog( val title: String, val artists: String, ) @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, clearAuthData: Boolean = false) { runCatching { Timber.tag("RESTORE").i("Starting restore from URI: $uri, clearAuthData: $clearAuthData") context.applicationContext.contentResolver.openInputStream(uri)?.use { raw -> raw.zipInputStream().use { inputStream -> var entry = tryOrNull { inputStream.nextEntry } // prevent ZipException var foundAny = false while (entry != null) { Timber.tag("RESTORE").i("Found zip entry: ${entry.name}") when (entry.name) { SETTINGS_FILENAME -> { Timber.tag("RESTORE").i("Restoring settings to datastore") foundAny = true (context.filesDir / "datastore" / SETTINGS_FILENAME).outputStream() .use { outputStream -> inputStream.copyTo(outputStream) } } InternalDatabase.DB_NAME -> { Timber.tag("RESTORE").i("Restoring DB (entry = ${entry.name})") foundAny = true // capture path before closing DB to avoid reopening race val dbPath = database.openHelper.writableDatabase.path runBlocking(Dispatchers.IO) { database.checkpoint() } database.close() Timber.tag("RESTORE").i("Overwriting DB at path: $dbPath") FileOutputStream(dbPath).use { outputStream -> inputStream.copyTo(outputStream) } Timber.tag("RESTORE").i("DB overwrite complete") } else -> { Timber.tag("RESTORE").i("Skipping unexpected entry: ${entry.name}") } } entry = tryOrNull { inputStream.nextEntry } // prevent ZipException } if (!foundAny) { Timber.tag("RESTORE").w("No expected entries found in archive") } } } ?: run { Timber.tag("RESTORE").e("Could not open input stream for uri: $uri") } // Clear stale auth data to prevent playback issues if (clearAuthData) { Timber.tag("RESTORE").i("Clearing auth data to prevent stale session issues") runBlocking(Dispatchers.IO) { context.dataStore.edit { preferences -> preferences.remove(InnerTubeCookieKey) preferences.remove(VisitorDataKey) preferences.remove(DataSyncIdKey) } } } context.stopService(Intent(context, MusicService::class.java)) context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) } context.startActivity(intent) Runtime.getRuntime().exit(0) }.onFailure { reportException(it) Timber.tag("RESTORE").e(it, "Restore failed") Toast.makeText(context, R.string.restore_failed, Toast.LENGTH_SHORT).show() } } fun previewBackup(context: Context, uri: Uri): BackupPreviewInfo { return runCatching { context.applicationContext.contentResolver.openInputStream(uri)?.use { raw -> raw.zipInputStream().use { inputStream -> var entry = tryOrNull { inputStream.nextEntry } while (entry != null) { if (entry.name == SETTINGS_FILENAME) { val bytes = inputStream.readBytes() val content = bytes.decodeToString(throwOnInvalidSequence = false) // Check for auth data (SAPISID cookie indicates logged in) val hasAuthData = content.contains("SAPISID=") // Extract cookie string from backup val cookie = if (hasAuthData) { extractCookieFromPrefs(content) } else null return BackupPreviewInfo( hasAuthData = hasAuthData, accountName = null, accountEmail = null, accountImageUrl = null, cookie = cookie, ) } entry = tryOrNull { inputStream.nextEntry } } } } BackupPreviewInfo() }.getOrElse { Timber.tag("BACKUP_PREVIEW").e(it, "Failed to preview backup") BackupPreviewInfo() } } private fun extractCookieFromPrefs(content: String): String? { // Find innerTubeCookie key and extract the cookie value. // The proto format has the key followed by type markers and then the string value. val keyMarker = "innerTubeCookie" val keyIndex = content.indexOf(keyMarker) if (keyIndex == -1) return null val afterKey = content.substring(keyIndex + keyMarker.length) // Cookie starts after some proto markers and contains semicolon-separated values. // Look for the first cookie key pattern like "__Secure-" or "HSID=" etc. val cookiePatterns = listOf("__Secure-", "HSID=", "SSID=", "SID=", "SAPISID=") var cookieStart = -1 for (pattern in cookiePatterns) { val idx = afterKey.indexOf(pattern) if (idx != -1 && (cookieStart == -1 || idx < cookieStart)) { cookieStart = idx } } if (cookieStart == -1) return null // Find the end of the cookie (next control character or next key). val cookieContent = afterKey.substring(cookieStart) val cookieEnd = cookieContent.indexOfFirst { it.code < 32 && it != '\t' && it != '\n' && it != '\r' } val rawCookie = if (cookieEnd > 0) { cookieContent.substring(0, cookieEnd) } else { cookieContent.take(5000) // Reasonable max length } // Remove any control characters (newlines, etc.) that are invalid in HTTP headers. return rawCookie.replace(Regex("[\\x00-\\x1F\\x7F]"), "").trim() } suspend fun fetchAccountInfoFromBackup(cookie: String): BackupPreviewInfo? { return runCatching { // Parse cookie to get SAPISID for auth header val cookieMap = parseCookieString(cookie) val sapisid = cookieMap["SAPISID"] ?: return@runCatching null // Generate SAPISIDHASH auth header val origin = "https://music.youtube.com" val currentTime = System.currentTimeMillis() / 1000 val sapisidHash = sha1("$currentTime $sapisid $origin") val authHeader = "SAPISIDHASH ${currentTime}_$sapisidHash" val client = OkHttpClient() val requestBody = """{"context":{"client":{"clientName":"WEB_REMIX","clientVersion":"1.20240101.01.00"}}}""" .toRequestBody("application/json".toMediaType()) val request = Request.Builder() .url("https://music.youtube.com/youtubei/v1/account/account_menu?prettyPrint=false") .post(requestBody) .header("Cookie", cookie) .header("Authorization", authHeader) .header("Origin", origin) .header("Referer", "$origin/") .header("X-Origin", origin) .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .build() val response = client.newCall(request).execute() val responseBody = response.body?.string() ?: return@runCatching null // Parse the JSON response val json = Json { ignoreUnknownKeys = true } val jsonResponse = json.parseToJsonElement(responseBody).jsonObject // Navigate to activeAccountHeaderRenderer val header = jsonResponse["actions"] ?.jsonArray?.getOrNull(0) ?.jsonObject?.get("openPopupAction") ?.jsonObject?.get("popup") ?.jsonObject?.get("multiPageMenuRenderer") ?.jsonObject?.get("header") ?.jsonObject?.get("activeAccountHeaderRenderer") ?.jsonObject if (header != null) { val name = header["accountName"] ?.jsonObject?.get("runs") ?.jsonArray?.getOrNull(0) ?.jsonObject?.get("text") ?.jsonPrimitive?.content val email = header["email"] ?.jsonObject?.get("runs") ?.jsonArray?.getOrNull(0) ?.jsonObject?.get("text") ?.jsonPrimitive?.content val thumbnailUrl = header["accountPhoto"] ?.jsonObject?.get("thumbnails") ?.jsonArray?.getOrNull(0) ?.jsonObject?.get("url") ?.jsonPrimitive?.content if (name != null) { BackupPreviewInfo( hasAuthData = true, accountName = name, accountEmail = email, accountImageUrl = thumbnailUrl, cookie = cookie, ) } else null } else null }.getOrElse { Timber.tag("BACKUP_PREVIEW").e(it, "Failed to fetch account info from backup") null } } fun previewCsvFile(context: Context, uri: Uri): CsvImportState { val previewRows = mutableListOf>() val csvState: CsvImportState runCatching { context.contentResolver.openInputStream(uri)?.use { stream -> val lines = stream.bufferedReader().readLines() val rowsToPreview = lines.take(6).map { parseCsvLine(it) } previewRows.addAll(rowsToPreview) val hasHeader = lines.isNotEmpty() && lines[0].contains(",") csvState = CsvImportState( previewRows = previewRows, hasHeader = hasHeader, ) return csvState } }.onFailure { reportException(it) Toast.makeText(context, "Failed to preview CSV file", Toast.LENGTH_SHORT).show() } return CsvImportState() } suspend fun importPlaylistFromCsv( context: Context, uri: Uri, columnMapping: CsvImportState, onProgress: (Int) -> Unit = {}, onLogUpdate: (List) -> Unit = {}, ): ArrayList = kotlinx.coroutines.withContext(Dispatchers.IO) { val songs = arrayListOf() val recentLogs = mutableListOf() runCatching { context.contentResolver.openInputStream(uri)?.use { stream -> val lines = stream.bufferedReader().readLines() val startIndex = if (columnMapping.hasHeader) 1 else 0 val totalLines = lines.size - startIndex lines.drop(startIndex).forEachIndexed { index, line -> val parts = parseCsvLine(line) if (parts.isNotEmpty()) { if (columnMapping.artistColumnIndex < parts.size && columnMapping.titleColumnIndex < parts.size) { val title = parts[columnMapping.titleColumnIndex].trim() val artistStr = parts[columnMapping.artistColumnIndex].trim() if (title.isNotEmpty() && artistStr.isNotEmpty()) { val artists = artistStr.split(";", ",").map { it.trim() } .filter { it.isNotEmpty() } .map { ArtistEntity(id = "", name = it) } val mockSong = Song( song = SongEntity( id = "", title = title, ), artists = artists, ) songs.add(mockSong) val logEntry = ConvertedSongLog( title = title, artists = artists.joinToString(", ") { it.name }, ) recentLogs.add(0, logEntry) if (recentLogs.size > 3) { recentLogs.removeAt(recentLogs.size - 1) } onLogUpdate(recentLogs.toList()) } } } val progress = ((index + 1) * 100) / totalLines onProgress(progress) } } }.onFailure { reportException(it) } songs } suspend fun importPlaylistFromCsv(context: Context, uri: Uri): ArrayList { return importPlaylistFromCsv(context, uri, CsvImportState()) } private fun parseCsvLine(line: String): List { val result = mutableListOf() var current = StringBuilder() var inQuotes = false for (char in line) { when { char == '"' -> inQuotes = !inQuotes char == ',' && !inQuotes -> { result.add(current.toString()) current = StringBuilder() } else -> current.append(char) } } result.add(current.toString()) return result.map { it.trim().trim('"') } } 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.isNotEmpty() && lines.first().startsWith("#EXTM3U")) { lines.forEachIndexed { _, rawLine -> if (rawLine.startsWith("#EXTINF:")) { 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 } companion object { const val SETTINGS_FILENAME = "settings.preferences_pb" } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/BrowseViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.YTItem import com.metrolist.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class BrowseViewModel @Inject constructor( savedStateHandle: SavedStateHandle ) : ViewModel() { private val browseId: String? = savedStateHandle.get("browseId") val items = MutableStateFlow?>(emptyList()) val title = MutableStateFlow("") init { viewModelScope.launch { browseId?.let { YouTube.browse(browseId, null).onSuccess { result -> // Store the title title.value = result.title // Flatten the nested structure to get all YTItems val allItems = result.items.flatMap { it.items } items.value = allItems }.onFailure { reportException(it) } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/CachePlaylistViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.datasource.cache.SimpleCache import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.Song import com.metrolist.music.di.DownloadCache import com.metrolist.music.di.PlayerCache import com.metrolist.music.extensions.filterExplicit import com.metrolist.music.extensions.filterVideoSongs import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.time.LocalDateTime import javax.inject.Inject @HiltViewModel class CachePlaylistViewModel @Inject constructor( @ApplicationContext private val context: Context, private val database: MusicDatabase, @PlayerCache private val playerCache: SimpleCache, @DownloadCache private val downloadCache: SimpleCache ) : ViewModel() { private val _cachedSongs = MutableStateFlow>(emptyList()) val cachedSongs: StateFlow> = _cachedSongs init { viewModelScope.launch { while (true) { val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val cachedIds = playerCache.keys.toSet() val downloadedIds = downloadCache.keys.toSet() val pureCacheIds = cachedIds.subtract(downloadedIds) val songs = if (pureCacheIds.isNotEmpty()) { database.getSongsByIds(pureCacheIds.toList()) } else { emptyList() } val completeSongs = songs.filter { val contentLength = it.format?.contentLength contentLength != null && playerCache.isCached(it.song.id, 0, contentLength) } if (completeSongs.isNotEmpty()) { database.query { completeSongs.forEach { if (it.song.dateDownload == null) { update(it.song.copy(dateDownload = LocalDateTime.now())) } } } } _cachedSongs.value = completeSongs .filter { it.song.dateDownload != null } .sortedByDescending { it.song.dateDownload } .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs) delay(1000) } } } fun removeSongFromCache(songId: String) { playerCache.removeResource(songId) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/ChartsViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.pages.ChartsPage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ChartsViewModel @Inject constructor() : ViewModel() { private val _chartsPage = MutableStateFlow(null) val chartsPage = _chartsPage.asStateFlow() private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() private val _error = MutableStateFlow(null) val error = _error.asStateFlow() fun loadCharts() { viewModelScope.launch { _isLoading.value = true _error.value = null YouTube.getChartsPage() .onSuccess { page -> _chartsPage.value = page } .onFailure { e -> _error.value = "Failed to load charts: ${e.message}" } _isLoading.value = false } } fun loadMore() { viewModelScope.launch { _chartsPage.value?.continuation?.let { continuation -> _isLoading.value = true YouTube.getChartsPage(continuation) .onSuccess { newPage -> _chartsPage.value = _chartsPage.value?.copy( sections = _chartsPage.value?.sections.orEmpty() + newPage.sections, continuation = newPage.continuation ) } .onFailure { e -> _error.value = "Failed to load more: ${e.message}" } _isLoading.value = false } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/ExploreViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.filterExplicit import com.metrolist.innertube.pages.ExplorePage import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.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() } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/HistoryViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.pages.HistoryPage import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.HistorySource import com.metrolist.music.db.MusicDatabase import com.metrolist.music.utils.dataStore import com.metrolist.music.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.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest 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 @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class HistoryViewModel @Inject constructor( @ApplicationContext private val context: Context, 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 = MutableStateFlow(null) val events = context.dataStore.data .map { it[HideVideoSongsKey] ?: false } .distinctUntilChanged() .flatMapLatest { hideVideoSongs -> database .events() .map { events -> events .filter { !hideVideoSongs || !it.song.song.isVideo } .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() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/HomeViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.datastore.preferences.core.edit import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.Artist import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.SongItem import kotlinx.coroutines.flow.combine import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.innertube.models.BrowseEndpoint import com.metrolist.innertube.models.YTItem import com.metrolist.innertube.models.filterExplicit import com.metrolist.innertube.models.filterVideoSongs import com.metrolist.innertube.models.filterYoutubeShorts import com.metrolist.innertube.pages.ExplorePage import com.metrolist.innertube.pages.HomePage import com.metrolist.innertube.utils.completed import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.HideYoutubeShortsKey import com.metrolist.music.constants.InnerTubeCookieKey import com.metrolist.music.constants.QuickPicks import com.metrolist.music.constants.QuickPicksKey import com.metrolist.music.constants.ShowWrappedCardKey import com.metrolist.music.constants.WrappedSeenKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.LocalItem import com.metrolist.music.db.entities.Song import com.metrolist.music.db.entities.SpeedDialItem import com.metrolist.music.extensions.filterVideoSongs import com.metrolist.music.extensions.toEnum import com.metrolist.music.models.SimilarRecommendation import com.metrolist.music.ui.screens.wrapped.WrappedAudioService import com.metrolist.music.ui.screens.wrapped.WrappedManager import com.metrolist.music.utils.SyncUtils import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow 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.time.LocalDate import javax.inject.Inject import kotlin.random.Random data class DailyDiscoverItem( val seed: Song, val recommendation: YTItem, val relatedEndpoint: BrowseEndpoint? ) data class CommunityPlaylistItem( val playlist: PlaylistItem, val songs: List ) @HiltViewModel class HomeViewModel @Inject constructor( @ApplicationContext val context: Context, val database: MusicDatabase, val syncUtils: SyncUtils, val wrappedManager: WrappedManager, private val wrappedAudioService: WrappedAudioService, ) : ViewModel() { val isRefreshing = MutableStateFlow(false) val isLoading = MutableStateFlow(false) val isRandomizing = MutableStateFlow(false) private val quickPicksEnum = context.dataStore.data.map { it[QuickPicksKey].toEnum(QuickPicks.QUICK_PICKS) }.distinctUntilChanged() val quickPicks = MutableStateFlow?>(null) val dailyDiscover = 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 communityPlaylists = MutableStateFlow?>(null) val selectedChip = MutableStateFlow(null) private val previousHomePage = MutableStateFlow(null) // Official API data for podcast sections val savedPodcastShows = MutableStateFlow>(emptyList()) val episodesForLater = MutableStateFlow>(emptyList()) val allLocalItems = MutableStateFlow>(emptyList()) val allYtItems = MutableStateFlow>(emptyList()) val pinnedSpeedDialItems: StateFlow> = database.speedDialDao.getAll() .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val speedDialItems: StateFlow> = combine( database.speedDialDao.getAll(), keepListening, quickPicks ) { pinned, keepListening, quick -> val pinnedItems = pinned.map { it.toYTItem() } val filled = pinnedItems.toMutableList() val targetSize = 27 if (filled.size < targetSize) { // Keep Listening (History/Heavy Rotation) keepListening?.let { k -> val needed = targetSize - filled.size val available = k.filter { item -> filled.none { p -> p.id == item.id } }.mapNotNull { item -> when (item) { is Song -> SongItem( id = item.id, title = item.title, artists = item.artists.map { Artist(name = it.name, id = it.id) }, thumbnail = item.thumbnailUrl ?: "", explicit = false ) is Album -> AlbumItem( browseId = item.id, playlistId = item.album.playlistId ?: "", title = item.title, artists = item.artists.map { Artist(name = it.name, id = it.id) }, year = item.album.year, thumbnail = item.thumbnailUrl ?: "" ) else -> null } } filled.addAll(available.take(needed)) } } if (filled.size < targetSize) { // Quick Picks quick?.let { q -> val needed = targetSize - filled.size val available = q.filter { song -> filled.none { p -> p.id == song.id } }.map { song -> SongItem( id = song.id, title = song.title, artists = song.artists.map { Artist(name = it.name, id = it.id) }, thumbnail = song.thumbnailUrl ?: "", explicit = false ) } filled.addAll(available.take(needed)) } } filled.take(targetSize) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) suspend fun getRandomItem(): YTItem? { try { isRandomizing.value = true // Visual feedback for the animation kotlinx.coroutines.delay(1000) val userSongs = mutableListOf() val otherSources = mutableListOf() quickPicks.value?.let { songs -> userSongs.addAll(songs.map { song -> SongItem( id = song.id, title = song.title, artists = song.artists.map { Artist(name = it.name, id = it.id) }, thumbnail = song.thumbnailUrl ?: "", explicit = false ) }) } keepListening.value?.let { items -> items.forEach { item -> when (item) { is Song -> userSongs.add(SongItem( id = item.id, title = item.title, artists = item.artists.map { Artist(name = it.name, id = it.id) }, thumbnail = item.thumbnailUrl ?: "", explicit = false )) is Album -> otherSources.add(AlbumItem( browseId = item.id, playlistId = item.album.playlistId ?: "", title = item.title, artists = item.artists.map { Artist(name = it.name, id = it.id) }, year = item.album.year, thumbnail = item.thumbnailUrl ?: "" )) else -> {} } } } otherSources.addAll(allYtItems.value) // Probability: 80% User Songs, 20% Other Sources val item = if (userSongs.isNotEmpty() && (otherSources.isEmpty() || Random.nextFloat() < 0.8f)) { userSongs.distinctBy { it.id }.shuffled().firstOrNull() } else { otherSources.distinctBy { it.id }.shuffled().firstOrNull() } ?: userSongs.firstOrNull() ?: otherSources.firstOrNull() return item } finally { isRandomizing.value = false } } val accountName = MutableStateFlow("Guest") val accountImageUrl = MutableStateFlow(null) val showWrappedCard: StateFlow = context.dataStore.data.map { prefs -> val showWrappedPref = prefs[ShowWrappedCardKey] ?: false val seen = prefs[WrappedSeenKey] ?: false val isBeforeDate = LocalDate.now().isBefore(LocalDate.of(2026, 2, 1)) isBeforeDate && (!seen || showWrappedPref) }.stateIn(viewModelScope, SharingStarted.Lazily, false) val wrappedSeen: StateFlow = context.dataStore.data.map { prefs -> prefs[WrappedSeenKey] ?: false }.stateIn(viewModelScope, SharingStarted.Lazily, false) fun togglePin(item: YTItem) { viewModelScope.launch(Dispatchers.IO) { val speedDialItem = SpeedDialItem.fromYTItem(item) val isPinned = database.speedDialDao.isPinned(speedDialItem.id).first() if (isPinned) { database.speedDialDao.delete(speedDialItem.id) } else { database.speedDialDao.insert(speedDialItem) } } } fun markWrappedAsSeen() { viewModelScope.launch(Dispatchers.IO) { context.dataStore.edit { it[WrappedSeenKey] = true } } } // Track last processed cookie to avoid unnecessary updates private var lastProcessedCookie: String? = null // Track if we're currently processing account data private var isProcessingAccountData = false private suspend fun getDailyDiscover() { val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val likedSongs = database.likedSongsByCreateDateAsc().first() if (likedSongs.isEmpty()) return val seeds = likedSongs.shuffled().distinctBy { it.id }.take(5) // Use a synchronized list to collect results safely from concurrent coroutines val items = java.util.Collections.synchronizedList(mutableListOf()) kotlinx.coroutines.coroutineScope { seeds.map { seed -> launch(Dispatchers.IO) { val endpoint = YouTube.next(WatchEndpoint(videoId = seed.id)).getOrNull()?.relatedEndpoint if (endpoint != null) { YouTube.related(endpoint).onSuccess { page -> val recommendations = page.songs .filter { item -> if (hideVideoSongs && item.isVideoSong) return@filter false if (item.explicit) return@filter false true } .shuffled() // Simple check to avoid immediate duplicate of seed val recommendation = recommendations.firstOrNull { rec -> rec.id != seed.id } if (recommendation != null) { items.add( DailyDiscoverItem( seed = seed, recommendation = recommendation, relatedEndpoint = endpoint ) ) } } } } }.forEach { it.join() } } // Final deduplication just in case multiple seeds recommended the same song dailyDiscover.value = items.toList().distinctBy { it.recommendation.id }.shuffled() } private suspend fun getQuickPicks() { val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) when (quickPicksEnum.first()) { QuickPicks.QUICK_PICKS -> { val relatedSongs = database.quickPicks().first().filterVideoSongs(hideVideoSongs) val forgotten = database.forgottenFavorites().first().filterVideoSongs(hideVideoSongs).take(8) // Get similar songs from YouTube based on recent listening val recentSong = database.events().first().firstOrNull()?.song val ytSimilarSongs = mutableListOf() if (recentSong != null) { val endpoint = YouTube.next(WatchEndpoint(videoId = recentSong.id)).getOrNull()?.relatedEndpoint if (endpoint != null) { YouTube.related(endpoint).onSuccess { page -> // Convert YouTube songs to local Song format if they exist in database page.songs.take(10).forEach { ytSong -> database.song(ytSong.id).first()?.let { localSong -> if (!hideVideoSongs || !localSong.song.isVideo) { ytSimilarSongs.add(localSong) } } } } } } // Combine all sources and remove duplicates val combined = (relatedSongs + forgotten + ytSimilarSongs) .distinctBy { it.id } .shuffled() .take(20) quickPicks.value = combined.ifEmpty { relatedSongs.shuffled().take(20) } } QuickPicks.LAST_LISTEN -> { val song = database.events().first().firstOrNull()?.song if (song != null && database.hasRelatedSongs(song.id)) { quickPicks.value = database.getRelatedSongs(song.id).first().filterVideoSongs(hideVideoSongs).shuffled().take(20) } } } } private suspend fun getCommunityPlaylists() { val fromTimeStamp = System.currentTimeMillis() - 86400000L * 7 * 4 val artistSeeds = database.mostPlayedArtists(fromTimeStamp, limit = 10).first() .filter { it.artist.isYouTubeArtist } .shuffled().take(3) val songSeeds = database.mostPlayedSongs(fromTimeStamp, limit = 5).first() .shuffled().take(2) val candidatePlaylists = java.util.Collections.synchronizedList(mutableListOf()) kotlinx.coroutines.coroutineScope { artistSeeds.map { seed -> launch(Dispatchers.IO) { YouTube.artist(seed.id).onSuccess { page -> page.sections.forEach { section -> section.items.filterIsInstance().forEach { playlist -> if (playlist.author?.name != "YouTube Music" && playlist.author?.name != "YouTube" && playlist.author?.name != "Playlist" && playlist.author?.name != seed.artist.name && !playlist.id.startsWith("RD") && !playlist.id.startsWith("OLAK") ) { candidatePlaylists.add(playlist) } } } } } } songSeeds.map { seed -> launch(Dispatchers.IO) { val endpoint = YouTube.next(WatchEndpoint(videoId = seed.id)).getOrNull()?.relatedEndpoint if (endpoint != null) { YouTube.related(endpoint).onSuccess { page -> page.playlists.forEach { playlist -> if (playlist.author?.name != "YouTube Music" && playlist.author?.name != "YouTube" && playlist.author?.name != "Playlist" && !playlist.id.startsWith("RD") && !playlist.id.startsWith("OLAK") ) { candidatePlaylists.add(playlist) } } } } } } } val uniqueCandidates = candidatePlaylists.distinctBy { it.id }.shuffled().take(5) val playlists = java.util.Collections.synchronizedList(mutableListOf()) kotlinx.coroutines.coroutineScope { uniqueCandidates.map { playlist -> launch(Dispatchers.IO) { YouTube.playlist(playlist.id).onSuccess { page -> val songs = page.songs.take(10) if (songs.isNotEmpty()) { // Use song count from the playlist page if available, otherwise use original val songCountText = page.playlist.songCountText ?: playlist.songCountText val updatedPlaylist = playlist.copy(songCountText = songCountText) playlists.add(CommunityPlaylistItem(updatedPlaylist, songs)) } } } }.forEach { it.join() } } communityPlaylists.value = playlists.shuffled() } private suspend fun load() { isLoading.value = true val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) val fromTimeStamp = System.currentTimeMillis() - 86400000L * 7 * 2 // Phase 1: Load essential sections in parallel — local DB (fast) + YouTube home page. // isLoading is set to false as soon as all Phase 1 tasks complete so the UI appears quickly. coroutineScope { launch(Dispatchers.IO) { getQuickPicks() } launch(Dispatchers.IO) { forgottenFavorites.value = database.forgottenFavorites().first() .filterVideoSongs(hideVideoSongs).shuffled().take(20) } launch(Dispatchers.IO) { val songs = database.mostPlayedSongs(fromTimeStamp, limit = 15, offset = 5).first() .filterVideoSongs(hideVideoSongs).shuffled().take(10) val albums = database.mostPlayedAlbums(fromTimeStamp, limit = 8, offset = 2).first() .filter { it.album.thumbnailUrl != null }.shuffled().take(5) val artists = database.mostPlayedArtists(fromTimeStamp).first() .filter { it.artist.isYouTubeArtist && it.artist.thumbnailUrl != null }.shuffled().take(5) keepListening.value = (songs + albums + artists).shuffled() } launch(Dispatchers.IO) { YouTube.home().onSuccess { page -> homePage.value = page.copy( sections = page.sections.mapNotNull { section -> val filtered = section.items .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs) .filterYoutubeShorts(hideYoutubeShorts) if (filtered.isEmpty()) null else section.copy(items = filtered) } ) }.onFailure { reportException(it) } } if (YouTube.cookie != null) { launch(Dispatchers.IO) { loadAccountPlaylists() } } } allLocalItems.value = (quickPicks.value.orEmpty() + forgottenFavorites.value.orEmpty() + keepListening.value.orEmpty()) .filter { it is Song || it is Album } isLoading.value = false // Phase 2: Heavy multi-request operations — run in background without blocking the UI. viewModelScope.launch(Dispatchers.IO) { getDailyDiscover() } viewModelScope.launch(Dispatchers.IO) { getCommunityPlaylists() } viewModelScope.launch(Dispatchers.IO) { YouTube.explore().onSuccess { page -> explorePage.value = page.copy( newReleaseAlbums = page.newReleaseAlbums.filterExplicit(hideExplicit) ) }.onFailure { reportException(it) } } viewModelScope.launch(Dispatchers.IO) { val artistRecommendations = database.mostPlayedArtists(fromTimeStamp, limit = 15).first() .filter { it.artist.isYouTubeArtist } .shuffled().take(4) .mapNotNull { val items = mutableListOf() YouTube.artist(it.id).onSuccess { page -> page.sections.takeLast(3).forEach { section -> items += section.items } } SimilarRecommendation( title = it, items = items .distinctBy { item -> item.id } .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs) .shuffled().take(12) .ifEmpty { return@mapNotNull null } ) } val songRecommendations = database.mostPlayedSongs(fromTimeStamp, limit = 15).first() .filter { it.album != null } .shuffled().take(3) .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(10) + page.albums.shuffled().take(5) + page.artists.shuffled().take(3) + page.playlists.shuffled().take(3)) .distinctBy { it.id } .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs) .shuffled() .ifEmpty { return@mapNotNull null } ) } val albumRecommendations = database.mostPlayedAlbums(fromTimeStamp, limit = 10).first() .filter { it.album.thumbnailUrl != null } .shuffled().take(2) .mapNotNull { album -> val items = mutableListOf() YouTube.album(album.id).onSuccess { page -> page.otherVersions.let { items += it } } album.artists.firstOrNull()?.id?.let { artistId -> YouTube.artist(artistId).onSuccess { page -> page.sections.lastOrNull()?.items?.let { items += it } } } SimilarRecommendation( title = album, items = items .distinctBy { it.id } .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs) .shuffled().take(10) .ifEmpty { return@mapNotNull null } ) } similarRecommendations.value = (artistRecommendations + songRecommendations + albumRecommendations).shuffled() allYtItems.value = similarRecommendations.value?.flatMap { it.items }.orEmpty() + homePage.value?.sections?.flatMap { it.items }.orEmpty() } } private val _isLoadingMore = MutableStateFlow(false) fun loadMoreYouTubeItems(continuation: String?) { if (continuation == null || _isLoadingMore.value) return val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) viewModelScope.launch(Dispatchers.IO) { _isLoadingMore.value = true val nextSections = YouTube.home(continuation).getOrNull() ?: run { _isLoadingMore.value = false return@launch } homePage.value = nextSections.copy( chips = homePage.value?.chips, sections = (homePage.value?.sections.orEmpty() + nextSections.sections).mapNotNull { section -> val filteredItems = section.items.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs).filterYoutubeShorts(hideYoutubeShorts) if (filteredItems.isEmpty()) null else section.copy(items = filteredItems) } ) _isLoadingMore.value = false } } fun toggleChip(chip: HomePage.Chip?) { if (chip == null || chip == selectedChip.value && previousHomePage.value != null) { homePage.value = previousHomePage.value previousHomePage.value = null selectedChip.value = null return } if (selectedChip.value == null) { previousHomePage.value = homePage.value } viewModelScope.launch(Dispatchers.IO) { val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) val nextSections = YouTube.home(params = chip.endpoint?.params).getOrNull() ?: return@launch homePage.value = nextSections.copy( chips = homePage.value?.chips, sections = nextSections.sections.map { section -> section.copy(items = section.items.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs).filterYoutubeShorts(hideYoutubeShorts)) } ) selectedChip.value = chip // Fetch podcast-specific data when podcasts chip is selected if (chip.title.contains("Podcast", ignoreCase = true)) { fetchPodcastData() } } } private suspend fun fetchPodcastData() { // Fetch saved podcast shows from official API YouTube.savedPodcastShows().onSuccess { shows -> savedPodcastShows.value = shows }.onFailure { reportException(it) } // Fetch episodes for later from official API YouTube.episodesForLater().onSuccess { episodes -> episodesForLater.value = episodes }.onFailure { reportException(it) } } private suspend fun loadAccountPlaylists() { val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) YouTube.library("FEmusic_liked_playlists").completed().onSuccess { accountPlaylists.value = it.items.filterIsInstance() .filterNot { it.id == "SE" } .filterYoutubeShorts(hideYoutubeShorts) }.onFailure { reportException(it) } } fun refresh() { if (isRefreshing.value) return isRefreshing.value = true viewModelScope.launch(Dispatchers.IO) { // If a chip is selected, reload the chip's content instead of the default home val currentChip = selectedChip.value if (currentChip != null) { val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) val nextSections = YouTube.home(params = currentChip.endpoint?.params).getOrNull() if (nextSections != null) { homePage.value = nextSections.copy( chips = homePage.value?.chips, sections = nextSections.sections.map { section -> section.copy(items = section.items.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs).filterYoutubeShorts(hideYoutubeShorts)) } ) } } else { load() } isRefreshing.value = false } // Run sync when user manually refreshes viewModelScope.launch(Dispatchers.IO) { syncUtils.tryAutoSync() } } init { // Load home data viewModelScope.launch(Dispatchers.IO) { context.dataStore.data .map { it[InnerTubeCookieKey] } .distinctUntilChanged() .first() load() } // Run sync in separate coroutine with cooldown to avoid blocking UI viewModelScope.launch(Dispatchers.IO) { syncUtils.tryAutoSync() } // Prepare wrapped data in background viewModelScope.launch(Dispatchers.IO) { showWrappedCard.collect { shouldShow -> if (shouldShow && !wrappedManager.state.value.isDataReady) { try { wrappedManager.prepare() val state = wrappedManager.state.first { it.isDataReady } val trackMap = state.trackMap if (trackMap.isNotEmpty()) { val firstTrackId = trackMap.entries.first().value wrappedAudioService.prepareTrack(firstTrackId) } } catch (e: Exception) { reportException(e) } } } } // Listen for cookie changes and reload account data viewModelScope.launch(Dispatchers.IO) { context.dataStore.data .map { it[InnerTubeCookieKey] } .collect { cookie -> // Avoid processing if already processing if (isProcessingAccountData) return@collect // Always process cookie changes, even if same value (for logout/login scenarios) lastProcessedCookie = cookie isProcessingAccountData = true try { if (cookie != null && cookie.isNotEmpty()) { // Update YouTube.cookie manually to ensure it's set YouTube.cookie = cookie // Fetch new account data YouTube.accountInfo().onSuccess { info -> accountName.value = info.name accountImageUrl.value = info.thumbnailUrl }.onFailure { reportException(it) } } else { accountName.value = "Guest" accountImageUrl.value = null accountPlaylists.value = null } } finally { isProcessingAccountData = false } } } // Listen for HideYoutubeShorts preference changes and reload account playlists instantly viewModelScope.launch(Dispatchers.IO) { context.dataStore.data .map { it[HideYoutubeShortsKey] ?: false } .distinctUntilChanged() .collect { if (YouTube.cookie != null && accountPlaylists.value != null) { loadAccountPlaylists() } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/LibraryViewModels.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ @file:OptIn(ExperimentalCoroutinesApi::class) package com.metrolist.music.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 com.metrolist.innertube.YouTube import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.utils.completed import com.metrolist.music.constants.AlbumFilter import com.metrolist.music.constants.AlbumFilterKey import com.metrolist.music.constants.AlbumSortDescendingKey import com.metrolist.music.constants.AlbumSortType import com.metrolist.music.constants.AlbumSortTypeKey import com.metrolist.music.constants.ArtistFilter import com.metrolist.music.constants.ArtistFilterKey import com.metrolist.music.constants.ArtistSongSortDescendingKey import com.metrolist.music.constants.ArtistSongSortType import com.metrolist.music.constants.ArtistSongSortTypeKey import com.metrolist.music.constants.ArtistSortDescendingKey import com.metrolist.music.constants.ArtistSortType import com.metrolist.music.constants.ArtistSortTypeKey import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.HideYoutubeShortsKey import com.metrolist.music.constants.LibraryFilter import com.metrolist.music.constants.PlaylistSortDescendingKey import com.metrolist.music.constants.PlaylistSortType import com.metrolist.music.constants.PlaylistSortTypeKey import com.metrolist.music.constants.SongFilter import com.metrolist.music.constants.SongFilterKey import com.metrolist.music.constants.SongSortDescendingKey import com.metrolist.music.constants.SongSortType import com.metrolist.music.constants.SongSortTypeKey import com.metrolist.music.constants.TopSize import com.metrolist.music.db.MusicDatabase import com.metrolist.music.extensions.filterExplicit import com.metrolist.music.extensions.filterExplicitAlbums import com.metrolist.music.extensions.filterVideoSongs import com.metrolist.music.extensions.filterYoutubeShorts import com.metrolist.music.extensions.toEnum import com.metrolist.music.playback.DownloadUtil import com.metrolist.music.utils.PodcastRefreshTrigger import com.metrolist.music.utils.SyncUtils import com.metrolist.music.utils.dataStore import com.metrolist.music.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.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged 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 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( Triple( it[SongFilterKey].toEnum(SongFilter.LIKED), it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE), (it[SongSortDescendingKey] ?: true), ), it[HideExplicitKey] ?: false, it[HideVideoSongsKey] ?: false ) }.distinctUntilChanged() .flatMapLatest { (filterSort, hideExplicit, hideVideoSongs) -> val (filter, sortType, descending) = filterSort when (filter) { SongFilter.LIBRARY -> database.songs(sortType, descending).map { it.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs) } SongFilter.LIKED -> database.likedSongs(sortType, descending).map { it.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs) } SongFilter.DOWNLOADED -> database.downloadedSongs(sortType, descending).map { it.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs) } SongFilter.UPLOADED -> database.uploadedSongs(sortType, descending).map { it.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs) } } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) fun syncLikedSongs() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLikedSongs() } } fun syncLibrarySongs() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncLibrarySongs() } } fun syncUploadedSongs() { viewModelScope.launch(Dispatchers.IO) { syncUtils.syncUploadedSongs() } } } @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.LIKED -> database.artistsBookmarked(sortType, descending) ArtistFilter.LIBRARY -> database.artists(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 { Pair( Triple( it[AlbumFilterKey].toEnum(AlbumFilter.LIKED), it[AlbumSortTypeKey].toEnum(AlbumSortType.CREATE_DATE), it[AlbumSortDescendingKey] ?: true, ), it[HideExplicitKey] ?: false ) }.distinctUntilChanged() .flatMapLatest { (filterSort, hideExplicit) -> val (filter, sortType, descending) = filterSort when (filter) { AlbumFilter.LIKED -> database.albumsLiked(sortType, descending).map { it.filterExplicitAlbums(hideExplicit) } AlbumFilter.LIBRARY -> database.albums(sortType, descending).map { it.filterExplicitAlbums(hideExplicit) } AlbumFilter.UPLOADED -> database.albumsUploaded(sortType, descending).map { it.filterExplicitAlbums(hideExplicit) } } }.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 { Triple( it[PlaylistSortTypeKey].toEnum(PlaylistSortType.CREATE_DATE), it[PlaylistSortDescendingKey] ?: true, it[HideYoutubeShortsKey] ?: false ) }.distinctUntilChanged() .flatMapLatest { (sortType, descending, hideYoutubeShorts) -> database.playlists(sortType, descending).map { it.filterYoutubeShorts(hideYoutubeShorts) } }.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 { Triple( it[ArtistSongSortTypeKey].toEnum(ArtistSongSortType.CREATE_DATE) to (it[ArtistSongSortDescendingKey] ?: true), it[HideExplicitKey] ?: false, it[HideVideoSongsKey] ?: false ) }.distinctUntilChanged() .flatMapLatest { (sortDesc, hideExplicit, hideVideoSongs) -> val (sortType, descending) = sortDesc database.artistSongs(artistId, sortType, descending).map { it.filterExplicit(hideExplicit).filterVideoSongs(hideVideoSongs) } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @HiltViewModel class LibraryMixViewModel @Inject constructor( @ApplicationContext context: Context, database: MusicDatabase, private val syncUtils: SyncUtils, ) : ViewModel() { private val _isRefreshing = MutableStateFlow(false) val isRefreshing = _isRefreshing.asStateFlow() val syncAllLibrary = { viewModelScope.launch(Dispatchers.IO) { syncUtils.tryAutoSync() } } fun refresh() { viewModelScope.launch(Dispatchers.IO) { _isRefreshing.value = true syncUtils.performFullSyncSuspend() _isRefreshing.value = false } } 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 = context.dataStore.data .map { it[HideExplicitKey] ?: false } .distinctUntilChanged() .flatMapLatest { hideExplicit -> database.albumsLiked(AlbumSortType.CREATE_DATE, true).map { it.filterExplicitAlbums(hideExplicit) } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) var playlists = context.dataStore.data .map { it[HideYoutubeShortsKey] ?: false } .distinctUntilChanged() .flatMapLatest { hideYoutubeShorts -> database.playlists(PlaylistSortType.CREATE_DATE, true).map { it.filterYoutubeShorts(hideYoutubeShorts) } }.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 LibraryPodcastsViewModel @Inject constructor( @ApplicationContext context: Context, private val database: MusicDatabase, private val syncUtils: SyncUtils, ) : ViewModel() { // Subscribed podcast channels synced from YT Music val subscribedChannels = database.subscribedPodcasts() .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) // SE "Episodes for Later" playlist fetched from YT Music (like AccountScreen) private val _sePlaylist = MutableStateFlow(null) val sePlaylist = _sePlaylist.asStateFlow() // RDPN "New Episodes" playlist fetched from YouTube Music (real thumbnail + episode count) private val _rdpnPlaylist = MutableStateFlow(null) val rdpnPlaylist = _rdpnPlaylist.asStateFlow() // Podcast host channels fetched from YT Music library/podcast_channels private val _apiPodcastChannels = MutableStateFlow>(emptyList()) // Podcast channels: API subscriptions + locally bookmarked artists that have podcasts // Only shows channels explicitly subscribed to (not derived from saved podcasts) val podcastChannels = kotlinx.coroutines.flow.combine( _apiPodcastChannels, database.bookmarkedPodcastChannels() ) { apiChannels, localPodcastChannels -> // Convert locally bookmarked podcast channels to ArtistItem format val localAsArtistItems = localPodcastChannels.map { artist -> ArtistItem( id = artist.id, title = artist.artist.name, thumbnail = artist.artist.thumbnailUrl, shuffleEndpoint = null, radioEndpoint = null, ) } // Combine and deduplicate by ID (prefer API version if exists) val apiIds = apiChannels.map { it.id }.toSet() val uniqueLocalChannels = localAsArtistItems.filter { it.id !in apiIds } apiChannels + uniqueLocalChannels }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) // Downloaded podcast episodes val downloadedEpisodes = context.dataStore.data .map { Pair( it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true), it[HideExplicitKey] ?: false ) }.distinctUntilChanged() .flatMapLatest { (sortDesc, hideExplicit) -> val (sortType, descending) = sortDesc database.downloadedPodcastEpisodes(sortType, descending).map { it.filterExplicit(hideExplicit) } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) // Saved podcast episodes (in library, not necessarily downloaded) val savedEpisodes = context.dataStore.data .map { Pair( it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true), it[HideExplicitKey] ?: false ) }.distinctUntilChanged() .flatMapLatest { (sortDesc, hideExplicit) -> val (sortType, descending) = sortDesc database.savedPodcastEpisodes(sortType, descending).map { it.filterExplicit(hideExplicit) } }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) private suspend fun fetchSePlaylist() { YouTube.library("FEmusic_liked_playlists").completed().onSuccess { _sePlaylist.value = it.items .filterIsInstance() .find { it.id == "SE" } }.onFailure { timber.log.Timber.e(it, "[PODCAST] Failed to fetch SE playlist") } } private suspend fun fetchPodcastChannels() { YouTube.libraryPodcastChannels().onSuccess { page -> val channels = page.items.filterIsInstance() _apiPodcastChannels.value = channels timber.log.Timber.d("[PODCAST] Fetched ${channels.size} podcast channels from YT Music") }.onFailure { timber.log.Timber.e(it, "[PODCAST] Failed to fetch podcast channels") } } private suspend fun fetchRdpnPlaylist() { YouTube.newEpisodesPlaylistInfo().onSuccess { item -> _rdpnPlaylist.value = item timber.log.Timber.d("[PODCAST] RDPN playlist: ${item.title}, thumbnail: ${item.thumbnail}") }.onFailure { timber.log.Timber.e(it, "[PODCAST] Failed to fetch RDPN playlist info") } } init { viewModelScope.launch(Dispatchers.IO) { fetchSePlaylist() } viewModelScope.launch(Dispatchers.IO) { fetchPodcastChannels() } viewModelScope.launch(Dispatchers.IO) { fetchRdpnPlaylist() } viewModelScope.launch(Dispatchers.IO) { syncUtils.syncPodcastSubscriptionsSuspend() } // Observe refresh trigger for auto-refresh after subscribe/unsubscribe viewModelScope.launch(Dispatchers.IO) { PodcastRefreshTrigger.refreshFlow.collect { // Small delay to allow YouTube's backend to update kotlinx.coroutines.delay(1500) fetchPodcastChannels() } } } fun clearPodcastData() { viewModelScope.launch(Dispatchers.IO) { syncUtils.clearPodcastData() } } suspend fun refreshAll() { fetchSePlaylist() fetchPodcastChannels() fetchRdpnPlaylist() syncUtils.syncPodcastSubscriptionsSuspend() syncUtils.syncEpisodesForLaterSuspend() } /** * Force refresh podcast channels. Called when screen becomes visible. */ fun refreshChannels() { viewModelScope.launch(Dispatchers.IO) { fetchPodcastChannels() } } } @HiltViewModel class LibraryViewModel @Inject constructor() : ViewModel() { private val curScreen = mutableStateOf(LibraryFilter.LIBRARY) val filter: MutableState = curScreen } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/ListenTogetherViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import androidx.lifecycle.ViewModel import com.metrolist.music.listentogether.ListenTogetherManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class ListenTogetherViewModel @Inject constructor( private val manager: ListenTogetherManager ) : ViewModel() { val connectionState = manager.connectionState val roomState = manager.roomState val role = manager.role val userId = manager.userId val pendingJoinRequests = manager.pendingJoinRequests val bufferingUsers = manager.bufferingUsers val logs = manager.logs val events = manager.events val hasPersistedSession = manager.hasPersistedSession val blockedUsernames = manager.blockedUsernames init { manager.initialize() } fun connect() { manager.connect() } fun disconnect() { manager.disconnect() } fun createRoom(username: String) { manager.createRoom(username) } fun joinRoom(roomCode: String, username: String) { manager.joinRoom(roomCode, username) } fun leaveRoom() { manager.leaveRoom() } fun approveJoin(userId: String) { manager.approveJoin(userId) } fun rejectJoin(userId: String, reason: String? = null) { manager.rejectJoin(userId, reason) } fun kickUser(userId: String, reason: String? = null) { manager.kickUser(userId, reason) } fun blockUser(username: String) { manager.blockUser(username) } fun unblockUser(username: String) { manager.unblockUser(username) } fun clearLogs() { manager.clearLogs() } fun forceReconnect() { manager.forceReconnect() } fun reconnect() { manager.forceReconnect() } fun getPersistedRoomCode(): String? = manager.getPersistedRoomCode() fun getSessionAge(): Long = manager.getSessionAge() } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/LocalPlaylistViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.PlaylistSongSortDescendingKey import com.metrolist.music.constants.PlaylistSongSortType import com.metrolist.music.constants.PlaylistSongSortTypeKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.PlaylistSong import com.metrolist.music.extensions.reversed import com.metrolist.music.extensions.toEnum import com.metrolist.music.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 { Triple( it[PlaylistSongSortTypeKey].toEnum(PlaylistSongSortType.CUSTOM), it[PlaylistSongSortDescendingKey] ?: true, it[HideVideoSongsKey] ?: false ) }.distinctUntilChanged(), ) { songs, (sortType, sortDescending, hideVideoSongs) -> val filteredSongs = if (hideVideoSongs) { songs.filter { !it.song.song.isVideo } } else { songs } when (sortType) { PlaylistSongSortType.CUSTOM -> filteredSongs PlaylistSongSortType.CREATE_DATE -> filteredSongs.sortedBy { it.map.id } PlaylistSongSortType.NAME -> { val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY filteredSongs.sortedWith(compareBy(collator) { it.song.song.title }) } PlaylistSongSortType.ARTIST -> { val collator = Collator.getInstance(Locale.getDefault()) collator.strength = Collator.PRIMARY filteredSongs .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 -> filteredSongs.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)) } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/LocalSearchViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.Album import com.metrolist.music.db.entities.Artist import com.metrolist.music.db.entities.LocalItem import com.metrolist.music.db.entities.Playlist import com.metrolist.music.db.entities.Song import com.metrolist.music.utils.dataStore 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.combine import kotlinx.coroutines.flow.distinctUntilChanged 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( @ApplicationContext context: Context, database: MusicDatabase, ) : ViewModel() { val query = MutableStateFlow("") val filter = MutableStateFlow(LocalFilter.ALL) val result = combine( query, filter, context.dataStore.data.map { it[HideVideoSongsKey] ?: false }.distinctUntilChanged() ) { query, filter, hideVideoSongs -> Triple(query, filter, hideVideoSongs) }.flatMapLatest { (query, filter, hideVideoSongs) -> 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 -> val filteredSongs = if (hideVideoSongs) songs.filter { !it.song.isVideo } else songs filteredSongs + albums + artists + playlists } LocalFilter.SONG -> database.searchSongs(query).map { songs -> if (hideVideoSongs) songs.filter { !it.song.isVideo } else songs } 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>, ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/LyricsMenuViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.LyricsEntity import com.metrolist.music.db.entities.Song import com.metrolist.music.lyrics.LyricsHelper import com.metrolist.music.lyrics.LyricsResult import com.metrolist.music.models.MediaMetadata import com.metrolist.music.utils.NetworkConnectivityObserver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job 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.runBlocking import javax.inject.Inject @HiltViewModel class LyricsMenuViewModel @Inject constructor( private val lyricsHelper: LyricsHelper, val database: MusicDatabase, private val networkConnectivity: NetworkConnectivityObserver, ) : ViewModel() { private var job: Job? = null val results = MutableStateFlow(emptyList()) val isLoading = MutableStateFlow(false) private val _isNetworkAvailable = MutableStateFlow(false) val isNetworkAvailable: StateFlow = _isNetworkAvailable.asStateFlow() private val _currentSong = mutableStateOf(null) val currentSong: State = _currentSong init { viewModelScope.launch { networkConnectivity.networkStatus.collect { isConnected -> _isNetworkAvailable.value = isConnected } } _isNetworkAvailable.value = try { networkConnectivity.isCurrentlyConnected() } catch (e: Exception) { true // Assume connected as fallback } } fun setCurrentSong(song: Song) { _currentSong.value = song } fun search( mediaId: String, title: String, artist: String, duration: Int, album: String? = null, ) { isLoading.value = true results.value = emptyList() job?.cancel() job = viewModelScope.launch(Dispatchers.IO) { lyricsHelper.getAllLyrics(mediaId, title, artist, duration, album) { 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 lyricsWithProvider = runBlocking { lyricsHelper.getLyrics(mediaMetadata) } upsert(LyricsEntity(mediaMetadata.id, lyricsWithProvider.lyrics, lyricsWithProvider.provider)) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/MoodAndGenresViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.pages.MoodAndGenres import com.metrolist.music.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) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/NewReleaseViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.filterExplicit import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.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() 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++ } } } _newReleaseAlbums.value = 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)) }.onFailure { reportException(it) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/OnlinePlaylistViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.filterVideoSongs import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import com.metrolist.music.constants.SongSortType import com.metrolist.innertube.models.Artist import com.metrolist.innertube.models.Album import javax.inject.Inject @HiltViewModel class OnlinePlaylistViewModel @Inject constructor( @ApplicationContext private val context: Context, savedStateHandle: SavedStateHandle, private val database: MusicDatabase ) : ViewModel() { private val playlistId = savedStateHandle.get("playlistId")!! // Check if this is a special podcast playlist (with or without VL prefix) private val normalizedPlaylistId = playlistId.removePrefix("VL") val isPodcastPlaylist = normalizedPlaylistId == "RDPN" || normalizedPlaylistId == "SE" val playlist = MutableStateFlow(null) val playlistSongs = MutableStateFlow>(emptyList()) private val _isLoading = MutableStateFlow(true) val isLoading = _isLoading.asStateFlow() private val _error = MutableStateFlow(null) val error = _error.asStateFlow() private val _isLoadingMore = MutableStateFlow(false) val isLoadingMore = _isLoadingMore.asStateFlow() val dbPlaylist = database.playlistByBrowseId(playlistId) .stateIn(viewModelScope, SharingStarted.Lazily, null) var continuation: String? = null private set private var proactiveLoadJob: Job? = null init { fetchInitialPlaylistData() } private fun fetchInitialPlaylistData() { viewModelScope.launch(Dispatchers.IO) { _isLoading.value = true _error.value = null continuation = null proactiveLoadJob?.cancel() // Cancel any ongoing proactive load if (isPodcastPlaylist) { // Use special podcast playlist APIs fetchPodcastPlaylist() } else { // Use regular playlist API fetchRegularPlaylist() } } } private suspend fun fetchPodcastPlaylist() { when (normalizedPlaylistId) { "RDPN" -> { YouTube.newEpisodes() .onSuccess { episodes -> playlist.value = PlaylistItem( id = playlistId, title = "New Episodes", author = null, songCountText = "${episodes.size} episodes", thumbnail = episodes.firstOrNull()?.thumbnail ?: "", playEndpoint = null, shuffleEndpoint = null, radioEndpoint = null, ) playlistSongs.value = applySongFilters(episodes) _isLoading.value = false }.onFailure { throwable -> _error.value = throwable.message ?: "Failed to load new episodes" _isLoading.value = false reportException(throwable) } } "SE" -> { timber.log.Timber.d("[SE_LOCAL] Fetching SE playlist...") val result = YouTube.episodesForLater() val episodes = result.getOrNull() ?: emptyList() timber.log.Timber.d("[SE_LOCAL] YouTube API result: ${if (result.isSuccess) "success" else "failed"}, ${episodes.size} episodes") if (result.isSuccess && episodes.isNotEmpty()) { // Use YouTube episodes playlist.value = PlaylistItem( id = playlistId, title = "Episodes for Later", author = null, songCountText = "${episodes.size} episodes", thumbnail = episodes.firstOrNull()?.thumbnail ?: "", playEndpoint = null, shuffleEndpoint = null, radioEndpoint = null, ) playlistSongs.value = applySongFilters(episodes) _isLoading.value = false } else { // Fall back to local saved episodes when API fails or returns empty timber.log.Timber.d("[SE_LOCAL] Falling back to local saved episodes") loadLocalSavedEpisodes() } } else -> { _error.value = "Unknown podcast playlist" _isLoading.value = false } } } private suspend fun fetchRegularPlaylist() { YouTube.playlist(playlistId) .onSuccess { playlistPage -> playlist.value = playlistPage.playlist playlistSongs.value = applySongFilters(playlistPage.songs) continuation = playlistPage.songsContinuation _isLoading.value = false if (continuation != null) { startProactiveBackgroundLoading() } }.onFailure { throwable -> _error.value = throwable.message ?: "Failed to load playlist" _isLoading.value = false reportException(throwable) } } private suspend fun loadLocalSavedEpisodes() { timber.log.Timber.d("[SE_LOCAL] loadLocalSavedEpisodes called") val savedEpisodes = database.savedPodcastEpisodes(SongSortType.CREATE_DATE, true).firstOrNull() ?: emptyList() timber.log.Timber.d("[SE_LOCAL] Found ${savedEpisodes.size} saved episodes") savedEpisodes.forEachIndexed { index, ep -> timber.log.Timber.d("[SE_LOCAL] Episode $index: id=${ep.song.id}, title=${ep.song.title}, isEpisode=${ep.song.isEpisode}, inLibrary=${ep.song.inLibrary}") } if (savedEpisodes.isNotEmpty()) { // Convert local Song entities to SongItem format val songItems = savedEpisodes.map { song -> SongItem( id = song.song.id, title = song.song.title, artists = song.artists.map { Artist(it.id, it.name) }, album = song.album?.let { com.metrolist.innertube.models.Album(it.id, it.title) }, duration = song.song.duration, thumbnail = song.song.thumbnailUrl ?: "", explicit = song.song.explicit, endpoint = null, ) } timber.log.Timber.d("[SE_LOCAL] Converted to ${songItems.size} SongItems") playlist.value = PlaylistItem( id = playlistId, title = "Episodes for Later", author = null, songCountText = "${songItems.size} episodes", thumbnail = songItems.firstOrNull()?.thumbnail ?: "", playEndpoint = null, shuffleEndpoint = null, radioEndpoint = null, ) val filtered = applySongFilters(songItems) timber.log.Timber.d("[SE_LOCAL] After filter: ${filtered.size} episodes, setting playlistSongs") playlistSongs.value = filtered _isLoading.value = false timber.log.Timber.d("[SE_LOCAL] Done, isLoading=false") } else { timber.log.Timber.d("[SE_LOCAL] No saved episodes found") _error.value = "No saved episodes" _isLoading.value = false } } private fun startProactiveBackgroundLoading() { proactiveLoadJob?.cancel() // Cancel previous job if any proactiveLoadJob = viewModelScope.launch(Dispatchers.IO) { var currentProactiveToken = continuation while (currentProactiveToken != null && isActive) { // If a manual loadMore is happening, pause proactive loading if (_isLoadingMore.value) { // Wait until manual load is finished, then re-evaluate // This simple break and restart strategy from loadMoreSongs is preferred break } YouTube.playlistContinuation(currentProactiveToken) .onSuccess { playlistContinuationPage -> val currentSongs = playlistSongs.value.toMutableList() currentSongs.addAll(playlistContinuationPage.songs) playlistSongs.value = applySongFilters(currentSongs) currentProactiveToken = playlistContinuationPage.continuation // Update the class-level continuation for manual loadMore if needed this@OnlinePlaylistViewModel.continuation = currentProactiveToken }.onFailure { throwable -> reportException(throwable) currentProactiveToken = null // Stop proactive loading on error } } // If loop finishes because currentProactiveToken is null, all songs are loaded proactively. } } fun loadMoreSongs() { if (_isLoadingMore.value) return // Already loading more (manually) val tokenForManualLoad = continuation ?: return // No more songs to load proactiveLoadJob?.cancel() // Cancel proactive loading to prioritize manual scroll _isLoadingMore.value = true viewModelScope.launch(Dispatchers.IO) { YouTube.playlistContinuation(tokenForManualLoad) .onSuccess { playlistContinuationPage -> val currentSongs = playlistSongs.value.toMutableList() currentSongs.addAll(playlistContinuationPage.songs) playlistSongs.value = applySongFilters(currentSongs) continuation = playlistContinuationPage.continuation }.onFailure { throwable -> reportException(throwable) }.also { _isLoadingMore.value = false // Resume proactive loading if there's still a continuation if (continuation != null && isActive) { startProactiveBackgroundLoading() } } } } fun retry() { proactiveLoadJob?.cancel() fetchInitialPlaylistData() // This will also restart proactive loading if applicable } private fun applySongFilters(songs: List): List { val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) return songs .distinctBy { it.id } .filterVideoSongs(hideVideoSongs) } override fun onCleared() { super.onCleared() proactiveLoadJob?.cancel() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/OnlinePodcastViewModel.kt ================================================ package com.metrolist.music.viewmodels import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.PodcastEntity import com.metrolist.music.utils.SyncUtils import com.metrolist.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import java.time.LocalDateTime import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class OnlinePodcastViewModel @Inject constructor( savedStateHandle: SavedStateHandle, val database: MusicDatabase, private val syncUtils: SyncUtils, ) : ViewModel() { private val podcastId = savedStateHandle.get("podcastId")!! val podcast = MutableStateFlow(null) val episodes = MutableStateFlow>(emptyList()) val libraryPodcast = podcast.flatMapLatest { p -> p?.let { database.podcast(it.id) } ?: flowOf(null) }.stateIn(viewModelScope, SharingStarted.Lazily, null) private val _isLoading = MutableStateFlow(true) val isLoading = _isLoading.asStateFlow() private val _error = MutableStateFlow(null) val error = _error.asStateFlow() init { Timber.d("ViewModel init with podcastId: $podcastId") fetchPodcastData() } private fun fetchPodcastData() { viewModelScope.launch(Dispatchers.IO) { Timber.d("fetchPodcastData called for: $podcastId") _isLoading.value = true _error.value = null YouTube.podcast(podcastId) .onSuccess { podcastPage -> Timber.d("Success! Podcast: ${podcastPage.podcast.title}, Episodes: ${podcastPage.episodes.size}") podcast.value = podcastPage.podcast episodes.value = podcastPage.episodes _isLoading.value = false }.onFailure { throwable -> Timber.e(throwable, "Failed to load podcast: ${throwable.message}") _error.value = throwable.message ?: "Failed to load podcast" _isLoading.value = false reportException(throwable) } } } /** * Toggle saving podcast to library. */ fun toggleSubscription() { val currentPodcast = podcast.value ?: run { Timber.d("[PODCAST_TOGGLE] No podcast loaded, returning") return } val existingEntity = libraryPodcast.value val isCurrentlySaved = existingEntity?.inLibrary == true val shouldBeSaved = !isCurrentlySaved val channelId = currentPodcast.channelId ?: currentPodcast.author?.id Timber.d("[PODCAST_TOGGLE] toggleSubscription called: podcastId=${currentPodcast.id}, channelId=$channelId, authorId=${currentPodcast.author?.id}, isCurrentlySaved=$isCurrentlySaved, shouldBeSaved=$shouldBeSaved") viewModelScope.launch(Dispatchers.IO) { Timber.d("[PODCAST_TOGGLE] Inside coroutine, updating database...") if (existingEntity != null) { val updated = existingEntity.toggleBookmark() Timber.d("[PODCAST_TOGGLE] Updating existing entity: bookmarkedAt=${updated.bookmarkedAt}") database.update(updated) } else { val newEntity = PodcastEntity( id = currentPodcast.id, title = currentPodcast.title, author = currentPodcast.author?.name, thumbnailUrl = currentPodcast.thumbnail, channelId = channelId, bookmarkedAt = LocalDateTime.now(), ) Timber.d("[PODCAST_TOGGLE] Inserting new entity: ${newEntity.id}") database.insert(newEntity) } Timber.d("[PODCAST_TOGGLE] Database updated, calling syncUtils.savePodcast(${currentPodcast.id}, $shouldBeSaved)") // Sync with YouTube (handles login check internally) syncUtils.savePodcast(currentPodcast.id, shouldBeSaved) } } /** * Legacy method - now calls toggleSubscription */ fun toggleLibrary() = toggleSubscription() fun retry() { fetchPodcastData() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/OnlineSearchSuggestionViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.YTItem import com.metrolist.innertube.models.filterExplicit import com.metrolist.innertube.models.filterVideoSongs import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.SearchHistory import com.metrolist.music.utils.dataStore import com.metrolist.music.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() val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) database .searchHistory(query) .map { it.take(3) } .map { history -> SearchSuggestionViewState( history = history, suggestions = result ?.queries ?.filter { suggestionQuery -> history.none { it.query == suggestionQuery } }.orEmpty(), items = result ?.recommendedItems ?.distinctBy { it.id } ?.filterExplicit(hideExplicit) ?.filterVideoSongs(hideVideoSongs) .orEmpty(), ) } } }.collect { _viewState.value = it } } } } data class SearchSuggestionViewState( val history: List = emptyList(), val suggestions: List = emptyList(), val items: List = emptyList(), ) ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/OnlineSearchViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.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.metrolist.innertube.YouTube import com.metrolist.innertube.models.filterExplicit import com.metrolist.innertube.models.filterVideoSongs import com.metrolist.innertube.models.filterYoutubeShorts import com.metrolist.innertube.pages.SearchSummaryPage import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.HideYoutubeShortsKey import com.metrolist.music.models.ItemsPage import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import java.net.URLDecoder import javax.inject.Inject @HiltViewModel class OnlineSearchViewModel @Inject constructor( @ApplicationContext val context: Context, savedStateHandle: SavedStateHandle, ) : ViewModel() { val query = try { URLDecoder.decode(savedStateHandle.get("query")!!, "UTF-8") } catch (e: IllegalArgumentException) { savedStateHandle.get("query")!! } val filter = MutableStateFlow(null) var summaryPage by mutableStateOf(null) val viewStateMap = mutableStateMapOf() private suspend fun loadSummaryPage() { if (summaryPage == null) { YouTube .searchSummary(query) .onSuccess { val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) summaryPage = it.filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs) .filterYoutubeShorts(hideYoutubeShorts) }.onFailure { reportException(it) } } } init { viewModelScope.launch { filter.collect { filter -> if (filter == null) { loadSummaryPage() } else if (filter == YouTube.SearchFilter.FILTER_EPISODE) { // The FILTER_EPISODE API returns episodes in a format that differs from the // summary search: playlistItemData is absent and the subtitle structure is // different, making reliable isEpisode detection fail for many items. // Reuse the "Episodes" section from the summary page instead — it is already // parsed correctly by fromMusicResponsiveListItemRenderer and guaranteed to // show the same results as the episodes section in the "All" filter. if (viewStateMap[filter.value] == null) { loadSummaryPage() summaryPage?.let { page -> val episodes = page.summaries .firstOrNull { it.title == "Episodes" } ?.items .orEmpty() viewStateMap[filter.value] = ItemsPage(episodes, null) } } } else { if (viewStateMap[filter.value] == null) { YouTube .search(query, filter) .onSuccess { result -> val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) viewStateMap[filter.value] = ItemsPage( result.items .distinctBy { it.id } .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs) .filterYoutubeShorts(hideYoutubeShorts), result.continuation, ) }.onFailure { reportException(it) } } } } } } fun loadMore() { val currentFilter = filter.value val filterValue = currentFilter?.value ?: return viewModelScope.launch { val viewState = viewStateMap[filterValue] ?: return@launch val continuation = viewState.continuation ?: return@launch val searchResult = YouTube.searchContinuation(continuation).getOrNull() ?: return@launch val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) val newItems = searchResult.items .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs) .filterYoutubeShorts(hideYoutubeShorts) viewStateMap[filterValue] = ItemsPage( (viewState.items + newItems).distinctBy { it.id }, searchResult.continuation ) } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/PlaylistsViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ @file:OptIn(ExperimentalCoroutinesApi::class) package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.music.constants.AddToPlaylistSortDescendingKey import com.metrolist.music.constants.AddToPlaylistSortTypeKey import com.metrolist.music.constants.PlaylistSortType import com.metrolist.music.db.MusicDatabase import com.metrolist.music.extensions.toEnum import com.metrolist.music.utils.SyncUtils import com.metrolist.music.utils.dataStore import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class PlaylistsViewModel @Inject constructor( @ApplicationContext context: Context, database: MusicDatabase, private val syncUtils: SyncUtils, ) : ViewModel() { val allPlaylists = context.dataStore.data .map { it[AddToPlaylistSortTypeKey].toEnum(PlaylistSortType.CREATE_DATE) to (it[AddToPlaylistSortDescendingKey] ?: true) }.distinctUntilChanged() .flatMapLatest { (sortType, descending) -> database.playlists(sortType, descending) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) // Suspend function that waits for sync to complete suspend fun sync() { syncUtils.syncSavedPlaylists() } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/StatsViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.Artist import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.LastMonthlyMostPlaylistSyncKey import com.metrolist.music.constants.LastWeeklyMostPlaylistSyncKey import com.metrolist.music.constants.StatPeriod import com.metrolist.music.constants.statToPeriod import com.metrolist.music.db.MusicDatabase import com.metrolist.music.db.entities.PlaylistEntity import com.metrolist.music.db.entities.PlaylistSongMap import com.metrolist.music.ui.screens.OptionStats import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.reportException import androidx.datastore.preferences.core.edit import com.metrolist.music.R 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.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first 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.Instant import java.time.LocalDateTime import java.time.ZoneOffset import javax.inject.Inject import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.collections.emptyList @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class StatsViewModel @Inject constructor( @ApplicationContext private val context: Context, val database: MusicDatabase, ) : ViewModel() { private val periodicMostPlaylistSyncMutex = Mutex() val selectedOption = MutableStateFlow(OptionStats.CONTINUOUS) val indexChips = MutableStateFlow(0) val mostPlayedSongsStats = combine( selectedOption, indexChips, context.dataStore.data.map { it[HideVideoSongsKey] ?: false }.distinctUntilChanged() ) { first, second, third -> Triple(first, second, third) } .flatMapLatest { (selection, t, hideVideoSongs) -> 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) }, ).map { songs -> if (hideVideoSongs) songs.filter { !it.isVideo } else songs } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val mostPlayedSongs = combine( selectedOption, indexChips, context.dataStore.data.map { it[HideVideoSongsKey] ?: false }.distinctUntilChanged() ) { first, second, third -> Triple(first, second, third) } .flatMapLatest { (selection, t, hideVideoSongs) -> 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) }, ).map { songs -> if (hideVideoSongs) songs.filter { !it.song.isVideo } else songs } }.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) val selectedArtists = mutableStateListOf() // Current artist selection val filteredSongs = combine( mostPlayedSongsStats, // Unfiltered songs snapshotFlow { selectedArtists.toList() } // Selected artists ) { songs, selected -> if (selected.isEmpty()) { songs } else { songs.filter { song -> song.artists.any { artist -> selected.any { it.id == artist.id } } } } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val filteredArtists = combine( mostPlayedArtists, // Unfiltered list of artists snapshotFlow { selectedArtists.toList() } // Selected artists ) { artists, selected -> if (selected.isEmpty()) { artists } else { artists.filter { artist -> selected.any { it.id == artist.artist.id } } } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val filteredAlbums = combine( mostPlayedAlbums, // Unfiltered list of albums snapshotFlow { selectedArtists.toList() } // Selected artists ) { albums, selected -> if (selected.isEmpty()) { albums } else { albums.filter { album -> album.artists.any { artist -> selected.any { it.id == artist.id } } } } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) fun transferSongStats(fromSongId: String, toSongId: String, onDone: (() -> Unit)? = null) { viewModelScope.launch { try { database.transferSongStats(fromSongId, toSongId) syncMostPlaylistsIfNeeded(force = true) onDone?.invoke() } catch (t: Throwable) { reportException(t) } } } val weeklyMostPlaylist = database .playlist(PlaylistEntity.WEEKLY_MOST_PLAYLIST_ID) .stateIn(viewModelScope, SharingStarted.Lazily, null) val monthlyMostPlaylist = database .playlist(PlaylistEntity.MONTHLY_MOST_PLAYLIST_ID) .stateIn(viewModelScope, SharingStarted.Lazily, null) val recapPlaylists = database .playlistsByNameAsc() .map { playlists -> playlists.filter { playlist -> playlist.playlist.browseId != null && playlist.playlist.name.contains("recap", ignoreCase = true) } }.distinctUntilChanged() .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) fun syncMostPlaylistsIfNeeded(force: Boolean = false) { viewModelScope.launch(Dispatchers.IO) { periodicMostPlaylistSyncMutex.withLock { val now = LocalDateTime.now(ZoneOffset.UTC) val nowEpochMillis = now.toInstant(ZoneOffset.UTC).toEpochMilli() val preferences = context.dataStore.data.first() val hideVideoSongs = preferences[HideVideoSongsKey] ?: false val weeklyPlaylistExists = database.playlist(PlaylistEntity.WEEKLY_MOST_PLAYLIST_ID).first() != null val monthlyPlaylistExists = database.playlist(PlaylistEntity.MONTHLY_MOST_PLAYLIST_ID).first() != null val shouldSyncWeekly = force || !weeklyPlaylistExists || isWeeklySyncDue( lastSyncMillis = preferences[LastWeeklyMostPlaylistSyncKey], now = now, ) val shouldSyncMonthly = force || !monthlyPlaylistExists || isMonthlySyncDue( lastSyncMillis = preferences[LastMonthlyMostPlaylistSyncKey], now = now, ) if (!shouldSyncWeekly && !shouldSyncMonthly) { return@withLock } if (shouldSyncWeekly) { syncMostPlaylist( playlistId = PlaylistEntity.WEEKLY_MOST_PLAYLIST_ID, playlistName = context.getString(R.string.weekly_most_playlist_name), fromTimeStamp = StatPeriod.WEEK_1.toTimeMillis(), hideVideoSongs = hideVideoSongs, now = now, ) } if (shouldSyncMonthly) { syncMostPlaylist( playlistId = PlaylistEntity.MONTHLY_MOST_PLAYLIST_ID, playlistName = context.getString(R.string.monthly_most_playlist_name), fromTimeStamp = StatPeriod.MONTH_1.toTimeMillis(), hideVideoSongs = hideVideoSongs, now = now, ) } // Only write "last sync" when it was a scheduled sync, not a forced rebuild if (!force) { context.dataStore.edit { settings -> if (shouldSyncWeekly) settings[LastWeeklyMostPlaylistSyncKey] = nowEpochMillis if (shouldSyncMonthly) settings[LastMonthlyMostPlaylistSyncKey] = nowEpochMillis } } } } } private fun isWeeklySyncDue( lastSyncMillis: Long?, now: LocalDateTime, ): Boolean { if (lastSyncMillis == null || lastSyncMillis <= 0L) return true val lastSyncAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMillis), ZoneOffset.UTC) return !lastSyncAt.plusWeeks(1).isAfter(now) } private fun isMonthlySyncDue( lastSyncMillis: Long?, now: LocalDateTime, ): Boolean { if (lastSyncMillis == null || lastSyncMillis <= 0L) return true val lastSyncAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(lastSyncMillis), ZoneOffset.UTC) return !lastSyncAt.plusMonths(1).isAfter(now) } private suspend fun syncMostPlaylist( playlistId: String, playlistName: String, fromTimeStamp: Long, hideVideoSongs: Boolean, now: LocalDateTime, ) { val songs = database .mostPlayedSongs( fromTimeStamp = fromTimeStamp, limit = -1, toTimeStamp = now.toInstant(ZoneOffset.UTC).toEpochMilli(), ).first() .let { mostPlayedSongs -> if (hideVideoSongs) { mostPlayedSongs.filter { !it.song.isVideo } } else { mostPlayedSongs } }.distinctBy { it.song.id } val existingPlaylist = database.playlist(playlistId).first()?.playlist val playlistEntity = existingPlaylist?.copy( name = playlistName, isEditable = true, bookmarkedAt = existingPlaylist.bookmarkedAt ?: now, lastUpdateTime = now, ) ?: PlaylistEntity( id = playlistId, name = playlistName, isEditable = true, bookmarkedAt = now, lastUpdateTime = now, ) if (existingPlaylist == null) { database.insert(playlistEntity) } else { database.update(playlistEntity) } database.clearPlaylist(playlistId) songs.forEachIndexed { position, song -> database.insert( PlaylistSongMap( songId = song.song.id, playlistId = playlistId, position = position, ), ) } } 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) } } } } } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/ThemeViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import androidx.lifecycle.ViewModel import com.metrolist.music.ui.screens.settings.DarkMode import com.metrolist.music.ui.theme.DefaultThemeColor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class ThemeViewModel : ViewModel() { // Theme state flows private val _darkMode = MutableStateFlow(DarkMode.AUTO) val darkMode: StateFlow = _darkMode.asStateFlow() private val _pureBlack = MutableStateFlow(false) val pureBlack: StateFlow = _pureBlack.asStateFlow() private val _selectedThemeColorInt = MutableStateFlow(DefaultThemeColor.hashCode()) val selectedThemeColorInt: StateFlow = _selectedThemeColorInt.asStateFlow() fun updateDarkMode(mode: DarkMode) { _darkMode.value = mode } fun updatePureBlack(enabled: Boolean) { _pureBlack.value = enabled } fun updateThemeColor(colorInt: Int) { _selectedThemeColorInt.value = colorInt } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/TopPlaylistViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.MyTopFilter import com.metrolist.music.db.MusicDatabase import com.metrolist.music.utils.dataStore 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.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map 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 = combine( topPeriod, context.dataStore.data.map { it[HideVideoSongsKey] ?: false }.distinctUntilChanged() ) { period, hideVideoSongs -> period to hideVideoSongs } .flatMapLatest { (period, hideVideoSongs) -> database.mostPlayedSongs(period.toTimeMillis(), top.toInt()).map { songs -> if (hideVideoSongs) songs.filter { !it.song.isVideo } else songs } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/viewmodels/YouTubeBrowseViewModel.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.viewmodels import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.metrolist.innertube.YouTube import com.metrolist.innertube.models.filterYoutubeShorts import com.metrolist.innertube.pages.BrowseResult import com.metrolist.music.constants.HideExplicitKey import com.metrolist.music.constants.HideVideoSongsKey import com.metrolist.music.constants.HideYoutubeShortsKey import com.metrolist.music.utils.dataStore import com.metrolist.music.utils.get import com.metrolist.music.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 { val hideExplicit = context.dataStore.get(HideExplicitKey, false) val hideVideoSongs = context.dataStore.get(HideVideoSongsKey, false) val hideYoutubeShorts = context.dataStore.get(HideYoutubeShortsKey, false) YouTube .browse(browseId, params) .onSuccess { result.value = it .filterExplicit(hideExplicit) .filterVideoSongs(hideVideoSongs) .filterYoutubeShorts(hideYoutubeShorts) }.onFailure { reportException(it) } } } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/widget/MetrolistWidgetManager.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.widget import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapShader import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.graphics.Shader import android.os.Bundle import android.widget.RemoteViews import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.request.crossfade import coil3.toBitmap import com.metrolist.music.MainActivity import com.metrolist.music.R import com.metrolist.music.db.MusicDatabase import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject import javax.inject.Singleton @Singleton class MetrolistWidgetManager @Inject constructor( @ApplicationContext private val context: Context, private val database: MusicDatabase ) { private val imageLoader by lazy { ImageLoader.Builder(context) .crossfade(false) .build() } // Cache for album art to avoid reloading private var cachedArtworkUri: String? = null private var cachedAlbumArt: Bitmap? = null private var cachedCircularAlbumArt: Bitmap? = null suspend fun updateWidgets( title: String, artist: String, artworkUri: String?, isPlaying: Boolean, isLiked: Boolean, duration: Long = 0, currentPosition: Long = 0 ) { val appWidgetManager = AppWidgetManager.getInstance(context) // Use cached album art if URI hasn't changed, otherwise load new one val albumArt: Bitmap? val circularAlbumArt: Bitmap? if (artworkUri != null && artworkUri == cachedArtworkUri && cachedAlbumArt != null) { albumArt = cachedAlbumArt circularAlbumArt = cachedCircularAlbumArt } else { albumArt = artworkUri?.let { loadAlbumArt(it, 300) } circularAlbumArt = albumArt?.let { getCircularBitmap(it) } // Update cache cachedArtworkUri = artworkUri cachedAlbumArt = albumArt cachedCircularAlbumArt = circularAlbumArt } // Update main music player widgets val componentName = ComponentName(context, MusicWidgetReceiver::class.java) val widgetIds = appWidgetManager.getAppWidgetIds(componentName) if (widgetIds.isNotEmpty()) { widgetIds.forEach { widgetId -> val options = appWidgetManager.getAppWidgetOptions(widgetId) val views = createRemoteViewsForSize( options, title, artist, albumArt, isPlaying, isLiked, duration, currentPosition ) appWidgetManager.updateAppWidget(widgetId, views) } } // Update turntable widgets val turntableComponentName = ComponentName(context, TurntableWidgetReceiver::class.java) val turntableWidgetIds = appWidgetManager.getAppWidgetIds(turntableComponentName) if (turntableWidgetIds.isNotEmpty()) { val turntableViews = createTurntableRemoteViews( circularAlbumArt, isPlaying, isLiked ) turntableWidgetIds.forEach { widgetId -> appWidgetManager.updateAppWidget(widgetId, turntableViews) } } } private fun createRemoteViewsForSize( options: Bundle, title: String, artist: String, albumArt: Bitmap?, isPlaying: Boolean, isLiked: Boolean, duration: Long, currentPosition: Long ): RemoteViews { val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) // Determine widget size category // 2x2: approximately 110dp x 110dp (compact square) // 4x1: approximately 250dp x 40dp (wide single row) // Full: approximately 250dp x 110dp (default) return when { minWidth < 180 && minHeight < 100 -> { // 2x2 Compact - Only play button with album art createCompactSquareRemoteViews(albumArt, isPlaying) } minWidth >= 180 && minHeight < 100 -> { // 4x1 Wide - Single row with album art, song info, like and play buttons createCompactWideRemoteViews(title, artist, albumArt, isPlaying, isLiked) } else -> { // Full layout createRemoteViews(title, artist, albumArt, isPlaying, isLiked, duration, currentPosition) } } } private fun createRemoteViews( title: String, artist: String, albumArt: Bitmap?, isPlaying: Boolean, isLiked: Boolean, duration: Long = 0, currentPosition: Long = 0 ): RemoteViews { val views = RemoteViews(context.packageName, R.layout.widget_music_player) // Set song info views.setTextViewText(R.id.widget_song_title, title) views.setTextViewText(R.id.widget_artist_name, artist) // Set album art with rounded corners if (albumArt != null) { val roundedAlbumArt = getRoundedCornerBitmap(albumArt, 48f) views.setImageViewBitmap(R.id.widget_album_art, roundedAlbumArt) } else { views.setImageViewBitmap(R.id.widget_album_art, getRoundedDefaultIcon(48f)) } // Set play/pause icon val playPauseIcon = if (isPlaying) R.drawable.ic_widget_pause else R.drawable.ic_widget_play views.setImageViewResource(R.id.widget_play_pause, playPauseIcon) // Set like icon - using nav style (purple) for main widget val likeIcon = if (isLiked) R.drawable.ic_widget_heart_nav else R.drawable.ic_widget_heart_outline_nav views.setImageViewResource(R.id.widget_like_button, likeIcon) // Set Progress Level if (duration > 0) { val level = ((currentPosition.toDouble() / duration.toDouble()) * 10000).toInt() views.setInt(R.id.widget_progress_fill, "setImageLevel", level) } else { views.setInt(R.id.widget_progress_fill, "setImageLevel", 0) } // Set click intents views.setOnClickPendingIntent(R.id.widget_album_art, getOpenAppIntent()) views.setOnClickPendingIntent(R.id.widget_play_pause_container, getPlayPauseIntent()) views.setOnClickPendingIntent(R.id.widget_like_button, getLikeIntent()) return views } private suspend fun loadAlbumArt(artworkUri: String, size: Int = 200): Bitmap? { return withContext(Dispatchers.IO) { try { val request = ImageRequest.Builder(context) .data(artworkUri) .size(size, size) .allowHardware(false) .crossfade(300) .build() val result = imageLoader.execute(request) result.image?.toBitmap() } catch (e: Exception) { null } } } private fun getRoundedCornerBitmap(bitmap: Bitmap, cornerRadius: Float): Bitmap { // Ensure the bitmap is square for thumbnails val size = minOf(bitmap.width, bitmap.height) val xOffset = (bitmap.width - size) / 2 val yOffset = (bitmap.height - size) / 2 val squareBitmap = Bitmap.createBitmap(bitmap, xOffset, yOffset, size, size) val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = Canvas(output) val paint = Paint().apply { isAntiAlias = true isFilterBitmap = true shader = BitmapShader(squareBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) } val rect = RectF(0f, 0f, size.toFloat(), size.toFloat()) canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) if (squareBitmap != bitmap) { squareBitmap.recycle() } return output } private fun getCircularBitmap(bitmap: Bitmap): Bitmap { val size = minOf(bitmap.width, bitmap.height) // First crop to square val xOffset = (bitmap.width - size) / 2 val yOffset = (bitmap.height - size) / 2 val squareBitmap = Bitmap.createBitmap(bitmap, xOffset, yOffset, size, size) // Create circular output val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = Canvas(output) val paint = Paint().apply { isAntiAlias = true isFilterBitmap = true shader = BitmapShader(squareBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) } val radius = size / 2f canvas.drawCircle(radius, radius, radius, paint) if (squareBitmap != bitmap) { squareBitmap.recycle() } return output } private fun createCompactSquareRemoteViews( albumArt: Bitmap?, isPlaying: Boolean ): RemoteViews { val views = RemoteViews(context.packageName, R.layout.widget_compact_square) // Set album art with rounded corners if (albumArt != null) { val roundedAlbumArt = getRoundedCornerBitmap(albumArt, 48f) views.setImageViewBitmap(R.id.widget_compact_album_art, roundedAlbumArt) } else { views.setImageViewBitmap(R.id.widget_compact_album_art, getRoundedDefaultIcon(48f)) } // Set play/pause icon - using low style icons val playPauseIcon = if (isPlaying) R.drawable.ic_widget_pause_low else R.drawable.ic_widget_play_low views.setImageViewResource(R.id.widget_compact_play_pause, playPauseIcon) // Set click intents views.setOnClickPendingIntent(R.id.widget_compact_album_art, getOpenAppIntent()) views.setOnClickPendingIntent(R.id.widget_compact_play_container, getPlayPauseIntent()) return views } private fun createCompactWideRemoteViews( title: String, artist: String, albumArt: Bitmap?, isPlaying: Boolean, isLiked: Boolean ): RemoteViews { val views = RemoteViews(context.packageName, R.layout.widget_compact_wide) // Set song info views.setTextViewText(R.id.widget_wide_song_title, title) views.setTextViewText(R.id.widget_wide_artist_name, artist) // Set album art with rounded corners (48f to match 12dp at ~4x density for 48dp view) if (albumArt != null) { val roundedAlbumArt = getRoundedCornerBitmap(albumArt, 48f) views.setImageViewBitmap(R.id.widget_wide_album_art, roundedAlbumArt) } else { // Create rounded default icon views.setImageViewBitmap(R.id.widget_wide_album_art, getRoundedDefaultIcon(48f)) } // Set play/pause icon - using low style icons val playPauseIcon = if (isPlaying) R.drawable.ic_widget_pause_low else R.drawable.ic_widget_play_low views.setImageViewResource(R.id.widget_wide_play_pause, playPauseIcon) // Set like icon - using navigation style (purple) val likeIcon = if (isLiked) R.drawable.ic_widget_heart_nav else R.drawable.ic_widget_heart_outline_nav views.setImageViewResource(R.id.widget_wide_like_button, likeIcon) // Set click intents views.setOnClickPendingIntent(R.id.widget_wide_album_art, getOpenAppIntent()) views.setOnClickPendingIntent(R.id.widget_wide_play_container, getPlayPauseIntent()) views.setOnClickPendingIntent(R.id.widget_wide_like_button, getLikeIntent()) return views } private fun createTurntableRemoteViews( circularAlbumArt: Bitmap?, isPlaying: Boolean, isLiked: Boolean ): RemoteViews { val views = RemoteViews(context.packageName, R.layout.widget_turntable) // Set circular album art - create circular default icon if no album art if (circularAlbumArt != null) { views.setImageViewBitmap(R.id.widget_turntable_album_art, circularAlbumArt) } else { // Load and make the default icon circular views.setImageViewBitmap(R.id.widget_turntable_album_art, getCircularDefaultIcon()) } // Set play/pause icon - using secondary color icons for turntable val playPauseIcon = if (isPlaying) R.drawable.ic_widget_pause_secondary else R.drawable.ic_widget_play_secondary views.setImageViewResource(R.id.widget_turntable_play_pause, playPauseIcon) // Set click intents views.setOnClickPendingIntent(R.id.widget_turntable_album_art, getOpenAppIntent()) views.setOnClickPendingIntent(R.id.widget_turntable_play_container, getTurntablePlayPauseIntent()) views.setOnClickPendingIntent(R.id.widget_turntable_prev_button, getTurntablePreviousIntent()) views.setOnClickPendingIntent(R.id.widget_turntable_next_button, getTurntableNextIntent()) return views } private fun getCircularDefaultIcon(): Bitmap { // Get the launcher icon and make it circular val drawable = context.packageManager.getApplicationIcon(context.packageName) val size = 300 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) drawable.setBounds(0, 0, size, size) drawable.draw(canvas) return getCircularBitmap(bitmap) } private fun getRoundedDefaultIcon(cornerRadius: Float): Bitmap { // Get the launcher icon and make it rounded val drawable = context.packageManager.getApplicationIcon(context.packageName) val size = 300 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) drawable.setBounds(0, 0, size, size) drawable.draw(canvas) return getRoundedCornerBitmap(bitmap, cornerRadius) } private fun getOpenAppIntent(): PendingIntent { val intent = Intent(context, MainActivity::class.java) return PendingIntent.getActivity( context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun getPlayPauseIntent(): PendingIntent { val intent = Intent(context, MusicWidgetReceiver::class.java).apply { action = MusicWidgetReceiver.ACTION_PLAY_PAUSE } return PendingIntent.getBroadcast( context, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun getLikeIntent(): PendingIntent { val intent = Intent(context, MusicWidgetReceiver::class.java).apply { action = MusicWidgetReceiver.ACTION_LIKE } return PendingIntent.getBroadcast( context, 2, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun getTurntablePlayPauseIntent(): PendingIntent { val intent = Intent(context, TurntableWidgetReceiver::class.java).apply { action = TurntableWidgetReceiver.ACTION_TURNTABLE_PLAY_PAUSE } return PendingIntent.getBroadcast( context, 3, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun getTurntableNextIntent(): PendingIntent { val intent = Intent(context, TurntableWidgetReceiver::class.java).apply { action = TurntableWidgetReceiver.ACTION_TURNTABLE_NEXT } return PendingIntent.getBroadcast( context, 4, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun getTurntablePreviousIntent(): PendingIntent { val intent = Intent(context, TurntableWidgetReceiver::class.java).apply { action = TurntableWidgetReceiver.ACTION_TURNTABLE_PREVIOUS } return PendingIntent.getBroadcast( context, 5, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/widget/MusicRecognizerWidgetReceiver.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.widget import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context import android.content.Intent import android.graphics.BitmapFactory import android.os.Build import android.os.Bundle import android.view.View import android.widget.RemoteViews import com.metrolist.music.MainActivity import com.metrolist.music.R import com.metrolist.music.recognition.MusicRecognitionService import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.ALBUM_ART_CACHE_FILE import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.PREF_ARTIST_NAME import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.PREF_COVER_ART_PATH import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.PREF_ERROR_MESSAGE import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.PREF_PULSE_FRAME import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.PREF_SONG_TITLE import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.PREF_STATE import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.PREFS_NAME import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.STATE_ERROR import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.STATE_IDLE import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.STATE_LISTENING import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.STATE_NO_MATCH import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.STATE_PROCESSING import com.metrolist.music.widget.MusicRecognizerWidgetService.Companion.STATE_SUCCESS import java.io.File /** * AppWidgetProvider for the Music Recognizer Widget. * * Sizes: * - 1×1 (minWidth < 110dp): Only the animated mic circle * - 1×3 (minWidth 110–229dp): Album art + song info + mic button (compact) * - 1×4 (minWidth ≥ 230dp): Album art + song info + mic button (wide, default) * * Click behaviour: * - Mic button → start / stop recognition * - Album art / text area (SUCCESS) → open app on recognition history screen * - Album art / text area (other) → open app on recognition screen */ class MusicRecognizerWidgetReceiver : AppWidgetProvider() { override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { updateAllWidgets(context, appWidgetManager) } override fun onAppWidgetOptionsChanged( context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle ) { super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) updateAllWidgets(context, appWidgetManager) } override fun onReceive(context: Context, intent: Intent) { super.onReceive(context, intent) when (intent.action) { ACTION_START_RECOGNITION -> handleStartRecognition(context) ACTION_UPDATE_WIDGET -> updateAllWidgets(context, AppWidgetManager.getInstance(context)) ACTION_RESET_STATE -> { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit() .putInt(PREF_STATE, STATE_IDLE) .putString(PREF_SONG_TITLE, "") .putString(PREF_ARTIST_NAME, "") .putString(PREF_ERROR_MESSAGE, "") .putString(PREF_COVER_ART_PATH, "") .putInt(PREF_PULSE_FRAME, 0) .apply() File(context.cacheDir, ALBUM_ART_CACHE_FILE).delete() updateAllWidgets(context, AppWidgetManager.getInstance(context)) } } } // ─── Recognition start / stop ───────────────────────────────────────────── private fun handleStartRecognition(context: Context) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val currentState = prefs.getInt(PREF_STATE, STATE_IDLE) // If active → stop if (currentState == STATE_LISTENING || currentState == STATE_PROCESSING) { context.startService( Intent(context, MusicRecognizerWidgetService::class.java).apply { action = MusicRecognizerWidgetService.ACTION_STOP_RECOGNITION } ) return } // Showing a result/error → clear it before starting a new search if (currentState == STATE_SUCCESS || currentState == STATE_NO_MATCH || currentState == STATE_ERROR) { prefs.edit().putInt(PREF_STATE, STATE_IDLE).apply() } // No mic permission → open the app so the user can grant it if (!MusicRecognitionService.hasRecordPermission(context)) { context.startActivity( Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_RECOGNITION flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP } ) return } // Start recognition foreground service val serviceIntent = Intent(context, MusicRecognizerWidgetService::class.java).apply { action = MusicRecognizerWidgetService.ACTION_START_RECOGNITION } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(serviceIntent) } else { context.startService(serviceIntent) } } // ─── Widget update ──────────────────────────────────────────────────────── private fun updateAllWidgets(context: Context, appWidgetManager: AppWidgetManager) { val componentName = ComponentName(context, MusicRecognizerWidgetReceiver::class.java) val widgetIds = appWidgetManager.getAppWidgetIds(componentName) val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val state = prefs.getInt(PREF_STATE, STATE_IDLE) val songTitle = prefs.getString(PREF_SONG_TITLE, "") ?: "" val artistName = prefs.getString(PREF_ARTIST_NAME, "") ?: "" val errorMessage = prefs.getString(PREF_ERROR_MESSAGE, "") ?: "" val coverArtPath = prefs.getString(PREF_COVER_ART_PATH, "") ?: "" val pulseFrame = prefs.getInt(PREF_PULSE_FRAME, 0) // Load album art bitmap from the cached file (synchronous, already on disk) val albumArtBitmap = if (state == STATE_SUCCESS && coverArtPath.isNotEmpty()) { try { BitmapFactory.decodeFile(coverArtPath) } catch (_: Exception) { null } } else null widgetIds.forEach { widgetId -> val options = appWidgetManager.getAppWidgetOptions(widgetId) val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) val views = when { minWidth < 110 -> createTinyViews(context, state, pulseFrame) minWidth < 230 -> createCompactViews(context, state, songTitle, artistName, errorMessage, albumArtBitmap, pulseFrame) else -> createWideViews(context, state, songTitle, artistName, errorMessage, albumArtBitmap, pulseFrame) } appWidgetManager.updateAppWidget(widgetId, views) } } // ─── Layout builders ────────────────────────────────────────────────────── private fun createWideViews( context: Context, state: Int, songTitle: String, artistName: String, errorMessage: String, albumArt: android.graphics.Bitmap?, pulseFrame: Int ): RemoteViews { val views = RemoteViews(context.packageName, R.layout.widget_recognizer_wide) applyAlbumArt(views, state, albumArt) applyTextState(context, views, state, songTitle, artistName, errorMessage) applyMicState(views, state, pulseFrame, R.id.widget_recognizer_mic_container, R.id.widget_recognizer_pulse) views.setOnClickPendingIntent(R.id.widget_recognizer_mic_container, getMicIntent(context)) val infoIntent = getInfoAreaIntent(context) views.setOnClickPendingIntent(R.id.widget_recognizer_text_area, infoIntent) views.setOnClickPendingIntent(R.id.widget_recognizer_album_art, infoIntent) return views } private fun createCompactViews( context: Context, state: Int, songTitle: String, artistName: String, errorMessage: String, albumArt: android.graphics.Bitmap?, pulseFrame: Int ): RemoteViews { val views = RemoteViews(context.packageName, R.layout.widget_recognizer_compact) applyAlbumArt(views, state, albumArt) applyTextState(context, views, state, songTitle, artistName, errorMessage) applyMicState(views, state, pulseFrame, R.id.widget_recognizer_mic_container, R.id.widget_recognizer_pulse) views.setOnClickPendingIntent(R.id.widget_recognizer_mic_container, getMicIntent(context)) val infoIntent = getInfoAreaIntent(context) views.setOnClickPendingIntent(R.id.widget_recognizer_text_area, infoIntent) views.setOnClickPendingIntent(R.id.widget_recognizer_album_art, infoIntent) return views } private fun createTinyViews( context: Context, state: Int, pulseFrame: Int ): RemoteViews { val views = RemoteViews(context.packageName, R.layout.widget_recognizer_tiny) applyMicState(views, state, pulseFrame, R.id.widget_recognizer_tiny_mic_container, R.id.widget_recognizer_tiny_pulse) views.setOnClickPendingIntent(R.id.widget_recognizer_tiny_root, getMicIntent(context)) return views } // ─── State helpers ──────────────────────────────────────────────────────── private fun applyAlbumArt( views: RemoteViews, state: Int, albumArt: android.graphics.Bitmap? ) { if (state == STATE_SUCCESS && albumArt != null) { views.setImageViewBitmap(R.id.widget_recognizer_album_art, albumArt) views.setViewVisibility(R.id.widget_recognizer_album_art, View.VISIBLE) } else { views.setViewVisibility(R.id.widget_recognizer_album_art, View.GONE) } } private fun applyTextState( context: Context, views: RemoteViews, state: Int, songTitle: String, artistName: String, errorMessage: String ) { when (state) { STATE_IDLE -> { views.setTextViewText(R.id.widget_recognizer_song_title, context.getString(R.string.widget_recognizer_tap_to_search)) views.setViewVisibility(R.id.widget_recognizer_artist_name, View.GONE) } STATE_LISTENING -> { views.setTextViewText(R.id.widget_recognizer_song_title, context.getString(R.string.widget_recognizer_listening)) views.setViewVisibility(R.id.widget_recognizer_artist_name, View.GONE) } STATE_PROCESSING -> { views.setTextViewText(R.id.widget_recognizer_song_title, context.getString(R.string.widget_recognizer_processing)) views.setViewVisibility(R.id.widget_recognizer_artist_name, View.GONE) } STATE_SUCCESS -> { views.setTextViewText(R.id.widget_recognizer_song_title, songTitle.ifEmpty { context.getString(R.string.widget_recognizer_unknown_song) }) views.setTextViewText(R.id.widget_recognizer_artist_name, artistName.ifEmpty { context.getString(R.string.widget_recognizer_unknown_artist) }) views.setViewVisibility(R.id.widget_recognizer_artist_name, View.VISIBLE) } STATE_NO_MATCH -> { views.setTextViewText(R.id.widget_recognizer_song_title, context.getString(R.string.widget_recognizer_no_match)) views.setViewVisibility(R.id.widget_recognizer_artist_name, View.GONE) } STATE_ERROR -> { views.setTextViewText(R.id.widget_recognizer_song_title, context.getString(R.string.widget_recognizer_error)) views.setTextViewText(R.id.widget_recognizer_artist_name, errorMessage.ifEmpty { context.getString(R.string.widget_recognizer_error_generic) }) views.setViewVisibility(R.id.widget_recognizer_artist_name, View.VISIBLE) } } } private fun applyMicState( views: RemoteViews, state: Int, pulseFrame: Int, micContainerId: Int, pulseViewId: Int ) { val isActive = state == STATE_LISTENING || state == STATE_PROCESSING views.setInt( micContainerId, "setBackgroundResource", if (isActive) R.drawable.widget_mic_button_bg_active else R.drawable.widget_mic_button_bg ) val pulseDrawable = if (isActive) { when (pulseFrame % 4) { 0 -> R.drawable.widget_mic_pulse_1 1 -> R.drawable.widget_mic_pulse_2 2 -> R.drawable.widget_mic_pulse_3 else -> R.drawable.widget_mic_pulse_4 } } else R.drawable.widget_mic_pulse_idle views.setImageViewResource(pulseViewId, pulseDrawable) } // ─── PendingIntents ─────────────────────────────────────────────────────── /** Tap on mic button → start or stop recognition */ private fun getMicIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( context, 20, Intent(context, MusicRecognizerWidgetReceiver::class.java).apply { action = ACTION_START_RECOGNITION }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) /** * Tap on song info area / album art → always open the recognition screen. * On SUCCESS the screen will show the result that the widget service already * set on [MusicRecognitionService.recognitionStatus]. */ private fun getInfoAreaIntent(context: Context): PendingIntent = PendingIntent.getActivity( context, 21, Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_RECOGNITION flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) companion object { const val ACTION_START_RECOGNITION = "com.metrolist.music.widget.recognizer.TAP_MIC" const val ACTION_UPDATE_WIDGET = "com.metrolist.music.widget.recognizer.UPDATE" const val ACTION_RESET_STATE = "com.metrolist.music.widget.recognizer.RESET" } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/widget/MusicRecognizerWidgetService.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.widget import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.BitmapShader import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF import android.graphics.Shader import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.request.crossfade import coil3.toBitmap import com.metrolist.music.MainActivity import com.metrolist.music.R import com.metrolist.music.db.DatabaseDao import com.metrolist.music.db.entities.RecognitionHistory import com.metrolist.music.recognition.MusicRecognitionService import com.metrolist.shazamkit.models.RecognitionStatus import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent import java.time.LocalDateTime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream @EntryPoint @InstallIn(SingletonComponent::class) interface RecognizerWidgetEntryPoint { fun databaseDao(): DatabaseDao } /** * Foreground service that handles music recognition for the widget. * Runs recognition in the foreground to allow microphone access, * downloads & caches the album art, then broadcasts results back to * the widget receiver. */ class MusicRecognizerWidgetService : Service() { private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var recognitionJob: Job? = null private var pulseJob: Job? = null private val imageLoader by lazy { ImageLoader.Builder(this).crossfade(false).build() } override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate() createNotificationChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_START_RECOGNITION -> { startForegroundNotification() startRecognition() } ACTION_STOP_RECOGNITION -> stopRecognitionAndService() } return START_NOT_STICKY } // ─── Foreground notification ────────────────────────────────────────────── private fun startForegroundNotification() { val openAppIntent = PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val stopIntent = PendingIntent.getService( this, 1, Intent(this, MusicRecognizerWidgetService::class.java).apply { action = ACTION_STOP_RECOGNITION }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_widget_mic) .setContentTitle(getString(R.string.widget_recognizer_listening)) .setContentText(getString(R.string.widget_recognizer_notification_text)) .setContentIntent(openAppIntent) .addAction( android.R.drawable.ic_menu_close_clear_cancel, getString(android.R.string.cancel), stopIntent ) .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW) .build() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { startForeground( NOTIFICATION_ID, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE ) } else { startForeground(NOTIFICATION_ID, notification) } } // ─── Recognition flow ───────────────────────────────────────────────────── private fun startRecognition() { saveState(STATE_LISTENING) updateAllWidgets() // Animate pulse rings while active pulseJob = serviceScope.launch { var frame = 0 while (isActive) { savePulseFrame(frame) updateAllWidgets() frame = (frame + 1) % PULSE_FRAME_COUNT delay(PULSE_INTERVAL_MS) } } recognitionJob = serviceScope.launch { try { val result = MusicRecognitionService.recognize(this@MusicRecognizerWidgetService) pulseJob?.cancel() val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) when (result) { is RecognitionStatus.Success -> { val artPath = downloadAndCacheAlbumArt( result.result.coverArtHqUrl ?: result.result.coverArtUrl )?.absolutePath ?: "" prefs.edit() .putInt(PREF_STATE, STATE_SUCCESS) .putString(PREF_SONG_TITLE, result.result.title) .putString(PREF_ARTIST_NAME, result.result.artist) .putString(PREF_COVER_ART_PATH, artPath) .putInt(PREF_PULSE_FRAME, 0) .apply() // Save to history so the result is persisted even if the user // never opens the recognition screen after seeing the widget result. try { val dao = EntryPointAccessors.fromApplication( applicationContext, RecognizerWidgetEntryPoint::class.java ).databaseDao() dao.insert( RecognitionHistory( trackId = result.result.trackId, title = result.result.title, artist = result.result.artist, album = result.result.album, coverArtUrl = result.result.coverArtUrl, coverArtHqUrl = result.result.coverArtHqUrl, genre = result.result.genre, releaseDate = result.result.releaseDate, label = result.result.label, shazamUrl = result.result.shazamUrl, appleMusicUrl = result.result.appleMusicUrl, spotifyUrl = result.result.spotifyUrl, isrc = result.result.isrc, youtubeVideoId = result.result.youtubeVideoId, recognizedAt = LocalDateTime.now() ) ) // Tell RecognitionScreen not to save again (avoid duplicate entry) MusicRecognitionService.resultSavedExternally = true } catch (_: Exception) { // Non-fatal – widget result is still displayed } } is RecognitionStatus.NoMatch -> { prefs.edit() .putInt(PREF_STATE, STATE_NO_MATCH) .putString(PREF_ERROR_MESSAGE, result.message) .putString(PREF_COVER_ART_PATH, "") .putInt(PREF_PULSE_FRAME, 0) .apply() } is RecognitionStatus.Error -> { prefs.edit() .putInt(PREF_STATE, STATE_ERROR) .putString(PREF_ERROR_MESSAGE, result.message) .putString(PREF_COVER_ART_PATH, "") .putInt(PREF_PULSE_FRAME, 0) .apply() } else -> { prefs.edit() .putInt(PREF_STATE, STATE_IDLE) .putString(PREF_COVER_ART_PATH, "") .putInt(PREF_PULSE_FRAME, 0) .apply() } } } catch (e: Exception) { pulseJob?.cancel() getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit() .putInt(PREF_STATE, STATE_ERROR) .putString(PREF_ERROR_MESSAGE, e.message ?: getString(R.string.widget_recognizer_error)) .putString(PREF_COVER_ART_PATH, "") .putInt(PREF_PULSE_FRAME, 0) .apply() } finally { updateAllWidgets() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } } } /** * Downloads [url], clips it to a rounded square (corner 24dp), and writes * the result to [ALBUM_ART_CACHE_FILE] inside the app cache directory. * Returns the file on success or null on any failure. */ private suspend fun downloadAndCacheAlbumArt(url: String?): File? { if (url.isNullOrBlank()) return null return withContext(Dispatchers.IO) { try { val request = ImageRequest.Builder(this@MusicRecognizerWidgetService) .data(url) .size(200, 200) .allowHardware(false) .build() val bitmap = imageLoader.execute(request).image?.toBitmap() ?: return@withContext null val rounded = getRoundedCornerBitmap(bitmap, 24f) val file = File(cacheDir, ALBUM_ART_CACHE_FILE) FileOutputStream(file).use { rounded.compress(Bitmap.CompressFormat.PNG, 90, it) } file } catch (_: Exception) { null } } } private fun getRoundedCornerBitmap(bitmap: Bitmap, cornerRadius: Float): Bitmap { val size = minOf(bitmap.width, bitmap.height) val xOff = (bitmap.width - size) / 2 val yOff = (bitmap.height - size) / 2 val square = Bitmap.createBitmap(bitmap, xOff, yOff, size, size) val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) val canvas = Canvas(output) val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { shader = BitmapShader(square, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) } canvas.drawRoundRect(RectF(0f, 0f, size.toFloat(), size.toFloat()), cornerRadius, cornerRadius, paint) if (square != bitmap) square.recycle() return output } // ─── Helpers ───────────────────────────────────────────────────────────── private fun stopRecognitionAndService() { recognitionJob?.cancel() pulseJob?.cancel() saveState(STATE_IDLE) savePulseFrame(0) updateAllWidgets() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } private fun saveState(state: Int) { getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) .edit().putInt(PREF_STATE, state).apply() } private fun savePulseFrame(frame: Int) { getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) .edit().putInt(PREF_PULSE_FRAME, frame).apply() } private fun updateAllWidgets() { sendBroadcast( Intent(this, MusicRecognizerWidgetReceiver::class.java).apply { action = MusicRecognizerWidgetReceiver.ACTION_UPDATE_WIDGET } ) } override fun onDestroy() { super.onDestroy() serviceScope.cancel() } private fun createNotificationChannel() { val channel = NotificationChannel( CHANNEL_ID, getString(R.string.widget_recognizer_channel_name), NotificationManager.IMPORTANCE_LOW ).apply { description = getString(R.string.widget_recognizer_channel_desc) setShowBadge(false) } getSystemService(NotificationManager::class.java).createNotificationChannel(channel) } // ─── Constants ──────────────────────────────────────────────────────────── companion object { const val ACTION_START_RECOGNITION = "com.metrolist.music.widget.recognizer.START" const val ACTION_STOP_RECOGNITION = "com.metrolist.music.widget.recognizer.STOP" const val PREFS_NAME = "recognizer_widget_prefs" const val PREF_STATE = "state" const val PREF_SONG_TITLE = "song_title" const val PREF_ARTIST_NAME = "artist_name" const val PREF_ERROR_MESSAGE = "error_message" const val PREF_PULSE_FRAME = "pulse_frame" const val PREF_COVER_ART_PATH = "cover_art_path" const val STATE_IDLE = 0 const val STATE_LISTENING = 1 const val STATE_PROCESSING = 2 const val STATE_SUCCESS = 3 const val STATE_NO_MATCH = 4 const val STATE_ERROR = 5 const val ALBUM_ART_CACHE_FILE = "recognizer_widget_art.png" private const val PULSE_FRAME_COUNT = 4 private const val PULSE_INTERVAL_MS = 600L private const val CHANNEL_ID = "music_recognizer_widget" private const val NOTIFICATION_ID = 9001 } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/widget/MusicWidgetReceiver.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.widget import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle import com.metrolist.music.playback.MusicService class MusicWidgetReceiver : AppWidgetProvider() { override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { // Only trigger update through MusicService if it's already running // This prevents BackgroundServiceStartNotAllowedException on Android 14+ if (MusicService.isRunning) { val intent = Intent(context, MusicService::class.java).apply { action = ACTION_UPDATE_WIDGET } try { context.startService(intent) } catch (e: Exception) { // Service might be restricted in background } } // If service is not running, widget shows default layout until user opens app } override fun onAppWidgetOptionsChanged( context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle ) { super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) // Trigger widget update when size changes if (MusicService.isRunning) { val intent = Intent(context, MusicService::class.java).apply { action = ACTION_UPDATE_WIDGET } try { context.startService(intent) } catch (e: Exception) { // Service might be restricted in background } } } override fun onReceive(context: Context, intent: Intent) { super.onReceive(context, intent) when (intent.action) { ACTION_PLAY_PAUSE, ACTION_LIKE, ACTION_NEXT, ACTION_PREVIOUS -> { // User interactions from widget buttons can start the service // Android allows starting FGS from widget PendingIntent clicks val serviceIntent = Intent(context, MusicService::class.java).apply { action = intent.action putExtras(intent) } try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startService(serviceIntent) } else { context.startService(serviceIntent) } } catch (e: Exception) { // Service might be restricted in background } } } } companion object { const val ACTION_PLAY_PAUSE = "com.metrolist.music.widget.PLAY_PAUSE" const val ACTION_LIKE = "com.metrolist.music.widget.LIKE" const val ACTION_NEXT = "com.metrolist.music.widget.NEXT" const val ACTION_PREVIOUS = "com.metrolist.music.widget.PREVIOUS" const val ACTION_UPDATE_WIDGET = "com.metrolist.music.widget.UPDATE_WIDGET" } } ================================================ FILE: app/src/main/kotlin/com/metrolist/music/widget/TurntableWidgetReceiver.kt ================================================ /** * Metrolist Project (C) 2026 * Licensed under GPL-3.0 | See git history for contributors */ package com.metrolist.music.widget import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent import com.metrolist.music.playback.MusicService class TurntableWidgetReceiver : AppWidgetProvider() { override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { // Only trigger update through MusicService if it's already running // This prevents BackgroundServiceStartNotAllowedException on Android 14+ if (MusicService.isRunning) { val intent = Intent(context, MusicService::class.java).apply { action = ACTION_UPDATE_TURNTABLE_WIDGET } try { context.startService(intent) } catch (e: Exception) { // Service might be restricted in background } } // If service is not running, widget shows default layout until user opens app } override fun onReceive(context: Context, intent: Intent) { super.onReceive(context, intent) when (intent.action) { ACTION_TURNTABLE_PLAY_PAUSE, ACTION_TURNTABLE_NEXT, ACTION_TURNTABLE_PREVIOUS -> { // User interactions from widget buttons can start the service // Android allows starting FGS from widget PendingIntent clicks val serviceIntent = Intent(context, MusicService::class.java).apply { action = when (intent.action) { ACTION_TURNTABLE_PLAY_PAUSE -> MusicWidgetReceiver.ACTION_PLAY_PAUSE ACTION_TURNTABLE_NEXT -> MusicWidgetReceiver.ACTION_NEXT ACTION_TURNTABLE_PREVIOUS -> MusicWidgetReceiver.ACTION_PREVIOUS else -> intent.action } putExtras(intent) } try { context.startService(serviceIntent) } catch (e: Exception) { // Service might be restricted in background } } } } companion object { const val ACTION_TURNTABLE_PLAY_PAUSE = "com.metrolist.music.widget.TURNTABLE_PLAY_PAUSE" const val ACTION_TURNTABLE_NEXT = "com.metrolist.music.widget.TURNTABLE_NEXT" const val ACTION_TURNTABLE_PREVIOUS = "com.metrolist.music.widget.TURNTABLE_PREVIOUS" const val ACTION_UPDATE_TURNTABLE_WIDGET = "com.metrolist.music.widget.UPDATE_TURNTABLE_WIDGET" } } ================================================ FILE: app/src/main/res/drawable/account.xml ================================================ ================================================ FILE: app/src/main/res/drawable/add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/add_circle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/album.xml ================================================ ================================================ FILE: app/src/main/res/drawable/alphabet_cyrillic.xml ================================================ ================================================ FILE: app/src/main/res/drawable/app_logo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_downward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_top_left.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_upward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/artist.xml ================================================ ================================================ FILE: app/src/main/res/drawable/backup.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_event_repeat_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bedtime.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bluetooth.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bug_report.xml ================================================ ================================================ FILE: app/src/main/res/drawable/buymeacoffee.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cached.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cast.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cast_connected.xml ================================================ ================================================ FILE: app/src/main/res/drawable/check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/clear_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/close.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cloud.xml ================================================ ================================================ FILE: app/src/main/res/drawable/content_copy.xml ================================================ ================================================ FILE: app/src/main/res/drawable/contrast.xml ================================================ ================================================ FILE: app/src/main/res/drawable/crop.xml ================================================ ================================================ FILE: app/src/main/res/drawable/crown.xml ================================================ ================================================ FILE: app/src/main/res/drawable/delete.xml ================================================ ================================================ FILE: app/src/main/res/drawable/delete_history.xml ================================================ ================================================ FILE: app/src/main/res/drawable/discord.xml ================================================ ================================================ FILE: app/src/main/res/drawable/discover_tune.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dock_to_top.xml ================================================ ================================================ FILE: app/src/main/res/drawable/done.xml ================================================ ================================================ FILE: app/src/main/res/drawable/download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/drag_handle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/edit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/equalizer.xml ================================================ ================================================ FILE: app/src/main/res/drawable/error.xml ================================================ ================================================ FILE: app/src/main/res/drawable/expand_less.xml ================================================ ================================================ FILE: app/src/main/res/drawable/expand_more.xml ================================================ ================================================ FILE: app/src/main/res/drawable/explicit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/explore_outlined.xml ================================================ ================================================ FILE: app/src/main/res/drawable/fast_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/favorite.xml ================================================ ================================================ FILE: app/src/main/res/drawable/favorite_border.xml ================================================ ================================================ FILE: app/src/main/res/drawable/fullscreen.xml ================================================ ================================================ FILE: app/src/main/res/drawable/github.xml ================================================ ================================================ FILE: app/src/main/res/drawable/gradient.xml ================================================ ================================================ FILE: app/src/main/res/drawable/graphic_eq.xml ================================================ ================================================ FILE: app/src/main/res/drawable/grid_view.xml ================================================ ================================================ FILE: app/src/main/res/drawable/group.xml ================================================ ================================================ FILE: app/src/main/res/drawable/group_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/group_filled.xml ================================================ ================================================ FILE: app/src/main/res/drawable/group_outlined.xml ================================================ ================================================ FILE: app/src/main/res/drawable/hide_image.xml ================================================ ================================================ FILE: app/src/main/res/drawable/history.xml ================================================ ================================================ FILE: app/src/main/res/drawable/home_filled.xml ================================================ ================================================ FILE: app/src/main/res/drawable/home_outlined.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_android_auto.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_dynamic_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_heart.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_heart_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_background_v31.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground_v31.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_monochrome.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_push_pin.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_heart_nav.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_heart_outline_nav.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_mic.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_pause_low.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_pause_secondary.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_play.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_play_low.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_play_secondary.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_skip_next.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_widget_skip_previous.xml ================================================ ================================================ FILE: app/src/main/res/drawable/info.xml ================================================ ================================================ FILE: app/src/main/res/drawable/insert_photo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/instagram.xml ================================================ ================================================ FILE: app/src/main/res/drawable/integration.xml ================================================ ================================================ FILE: app/src/main/res/drawable/key.xml ================================================ ================================================ FILE: app/src/main/res/drawable/language.xml ================================================ ================================================ FILE: app/src/main/res/drawable/language_japanese_latin.xml ================================================ ================================================ FILE: app/src/main/res/drawable/language_korean_latin.xml ================================================ ================================================ FILE: app/src/main/res/drawable/library_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/library_add_check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/library_music.xml ================================================ ================================================ FILE: app/src/main/res/drawable/library_music_filled.xml ================================================ ================================================ FILE: app/src/main/res/drawable/library_music_outlined.xml ================================================ ================================================ FILE: app/src/main/res/drawable/linear_scale.xml ================================================ ================================================ FILE: app/src/main/res/drawable/link.xml ================================================ ================================================ FILE: app/src/main/res/drawable/list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/location_on.xml ================================================ ================================================ FILE: app/src/main/res/drawable/lock.xml ================================================ ================================================ FILE: app/src/main/res/drawable/lock_open.xml ================================================ ================================================ FILE: app/src/main/res/drawable/login.xml ================================================ ================================================ FILE: app/src/main/res/drawable/logout.xml ================================================ ================================================ FILE: app/src/main/res/drawable/lyrics.xml ================================================ ================================================ FILE: app/src/main/res/drawable/manage_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/mic.xml ================================================ ================================================ FILE: app/src/main/res/drawable/more_horiz.xml ================================================ ================================================ FILE: app/src/main/res/drawable/more_time.xml ================================================ ================================================ FILE: app/src/main/res/drawable/more_vert.xml ================================================ ================================================ FILE: app/src/main/res/drawable/music_note.xml ================================================ ================================================ FILE: app/src/main/res/drawable/nav_bar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/navigate_next.xml ================================================ ================================================ FILE: app/src/main/res/drawable/newspaper.xml ================================================ ================================================ FILE: app/src/main/res/drawable/notification.xml ================================================ ================================================ FILE: app/src/main/res/drawable/offline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/palette.xml ================================================ ================================================ FILE: app/src/main/res/drawable/pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/person.xml ================================================ ================================================ FILE: app/src/main/res/drawable/play.xml ================================================ ================================================ FILE: app/src/main/res/drawable/playlist_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/playlist_play.xml ================================================ ================================================ FILE: app/src/main/res/drawable/queue_music.xml ================================================ ================================================ FILE: app/src/main/res/drawable/radio.xml ================================================ ================================================ FILE: app/src/main/res/drawable/radio_button_checked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/radio_button_unchecked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/refresh.xml ================================================ ================================================ FILE: app/src/main/res/drawable/remove.xml ================================================ ================================================ FILE: app/src/main/res/drawable/repeat.xml ================================================ ================================================ FILE: app/src/main/res/drawable/repeat_on.xml ================================================ ================================================ FILE: app/src/main/res/drawable/repeat_one.xml ================================================ ================================================ FILE: app/src/main/res/drawable/repeat_one_on.xml ================================================ ================================================ FILE: app/src/main/res/drawable/replay.xml ================================================ ================================================ FILE: app/src/main/res/drawable/restore.xml ================================================ ================================================ FILE: app/src/main/res/drawable/screenshot.xml ================================================ ================================================ FILE: app/src/main/res/drawable/search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/search_off.xml ================================================ ================================================ FILE: app/src/main/res/drawable/security.xml ================================================ ================================================ FILE: app/src/main/res/drawable/select_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/share.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shortcut_library.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shortcut_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shuffle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/shuffle_on.xml ================================================ ================================================ FILE: app/src/main/res/drawable/similar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/skip_next.xml ================================================ ================================================ FILE: app/src/main/res/drawable/skip_previous.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sliders.xml ================================================ ================================================ FILE: app/src/main/res/drawable/slow_motion_video.xml ================================================ ================================================ FILE: app/src/main/res/drawable/small_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/speed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/star.xml ================================================ ================================================ FILE: app/src/main/res/drawable/stats.xml ================================================ ================================================ FILE: app/src/main/res/drawable/storage.xml ================================================ ================================================ FILE: app/src/main/res/drawable/subscribe.xml ================================================ ================================================ FILE: app/src/main/res/drawable/subscribed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/swipe.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sync.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tab.xml ================================================ ================================================ FILE: app/src/main/res/drawable/telegram.xml ================================================ ================================================ FILE: app/src/main/res/drawable/time_auto.xml ================================================ ================================================ FILE: app/src/main/res/drawable/timer.xml ================================================ ================================================ FILE: app/src/main/res/drawable/timer_arrow_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/token.xml ================================================ ================================================ FILE: app/src/main/res/drawable/translate.xml ================================================ ================================================ FILE: app/src/main/res/drawable/trending_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tune.xml ================================================ ================================================ FILE: app/src/main/res/drawable/update.xml ================================================ ================================================ FILE: app/src/main/res/drawable/upload.xml ================================================ ================================================ FILE: app/src/main/res/drawable/volume_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/volume_mute.xml ================================================ ================================================ FILE: app/src/main/res/drawable/volume_off.xml ================================================ ================================================ FILE: app/src/main/res/drawable/volume_off_pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/volume_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/warning.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_like_button_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_mic_button_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_mic_button_bg_active.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_mic_pulse_1.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_mic_pulse_2.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_mic_pulse_3.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_mic_pulse_4.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_mic_pulse_idle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_play_button_circular.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_play_pill_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_progress_clip.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_progress_fill.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_progress_track.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_turntable_default_art.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_turntable_nav_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_turntable_play_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/wifi_proxy.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/widget_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/widget_play_pill_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/widget_progress_fill.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/widget_progress_track.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/widget_turntable_nav_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/widget_turntable_play_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v31/widget_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v31/widget_play_pill_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v31/widget_progress_fill.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v31/widget_progress_track.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v31/widget_turntable_nav_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v31/widget_turntable_play_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/ic_launcher_background_v31.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/ic_widget_mic.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_mic_button_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_mic_button_bg_active.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_mic_pulse_1.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_mic_pulse_2.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_mic_pulse_3.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_mic_pulse_4.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_play_pill_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_progress_fill.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_progress_track.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_turntable_nav_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v31/widget_turntable_play_bg.xml ================================================ ================================================ FILE: app/src/main/res/font/bbh_bartle.xml ================================================ ================================================ FILE: app/src/main/res/layout/widget_compact_square.xml ================================================ ================================================ FILE: app/src/main/res/layout/widget_compact_wide.xml ================================================ ================================================ FILE: app/src/main/res/layout/widget_music_player.xml ================================================ ================================================ FILE: app/src/main/res/layout/widget_recognizer_compact.xml ================================================ ================================================ FILE: app/src/main/res/layout/widget_recognizer_tiny.xml ================================================ ================================================ FILE: app/src/main/res/layout/widget_recognizer_wide.xml ================================================ ================================================ FILE: app/src/main/res/layout/widget_turntable.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi/ic_launcher_static.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi/ic_launcher_static_round.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v31/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/mipmap-anydpi-v31/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/main/res/resources.properties ================================================ unqualifiedResLocale=en ================================================ FILE: app/src/main/res/values/app_name.xml ================================================ Metrolist ================================================ FILE: app/src/main/res/values/colors.xml ================================================ #000000 #1C1B1F #49454F ================================================ FILE: app/src/main/res/values/ic_launcher_background.xml ================================================ #000000 #80CBC4 #00796B ================================================ FILE: app/src/main/res/values/metrolist_strings.xml ================================================ Local Remote Charts Back Album cover Top music videos Trending Weeks Months Years Continuous Liked Downloaded My top Weekly Most Monthly Most Cached Uploaded Uploaded Sync playlist Sync disabled Note: This allows for syncing with YouTube Music. This is NOT changeable later. Generating image Please wait Cancel Enable Share lyrics Share as text Share as image Max selection limit Share selected Customize colors Text color Secondary text color Background color Remove from cache About Show more Show less Artist page Show artist description Show subscriber count Show monthly listeners Download all songs for offline playback Remove all downloaded songs from this playlist Download is in progress Share this playlist with others Remove this playlist permanently Sync playlist with YouTube Music Copy link Select all Like all Dislike all Date updated Link copied to clipboard Starting radio Now Playing Lyrics Close Hide Player Thumbnail Replace album artwork with app logo in player Crop Album Art Force a square aspect ratio by cropping video thumbnails Already in playlist: %d time %d times +%1$d seconds forwards -%1$d seconds backwards Progressive seek If enabled, Adds up 5 extra seconds incrementally on each seek skip Similar content Player background style Solid Follow theme Gradient New player design New mini player design Blur Player button colors Default Primary color Tertiary color Display density Restart Restart required The display density change will take effect after restarting the app. Do you want to restart now? Wavy Enable swipe to change song Swipe song to the left to add it to the queue or to the right to play it next Swipe song to remove it from the playlist Change lyrics on click Auto scroll lyrics Enable glowing lyrics effect Add glowing animation and bounce effect to active lyrics Community-driven synchronized lyrics database Takes lyrics from KuGou, a popular Chinese music platform NOTE: Lyrics from YouTube Music will be automatically shown when other lyrics are not available. Lyrics from YTM are usually not synchronized. Enable Better Lyrics Syllable-synced lyrics for any song, for karaoke Enable SimpMusic Lyrics Automatically sourced lyrics from Musixmatch and YouTube Transcript Enable LyricsPlus Synchronized lyrics from multiple sources Provider selection Choose which lyrics providers are enabled Re-sync Slim Slim bottom navigation bar Auto playlists Show \"Liked\" playlist Show \"Downloaded\" playlist Show \"Top\" playlist Show \"Cached\" playlist Enable song cache Automatically cache songs for future playback Show \"Uploaded\" playlist Shuffle playlist/album first When shuffling, play all songs from the original playlist/album first, then similar content Prevent duplicate tracks in queue When adding a track to queue, remove it from its previous position if already present Show Wrapped card Fast forward through silent parts of songs Instantly skip silence Jump ahead during silent moments instead of speeding up playback Login with token Tap to show token Tap again to copy or edit This is an ADVANCED login method. As an alternative to the web portal, you may directly enter or update your login token here. For example, this can speed up logging in on multiple devices. Please note that any invalid token formats the app fails to parse will not be accepted Auto sync with account More content Sort ascending Sort descending Edit playlist cover Note: Your account must be linked to a phone number and verified on YouTube Music to change playlist cover. After selecting an image, please wait a moment for the new cover to appear in your playlist. Choose from library Remove custom image General Proxy Change default library chip Set quick picks Based on last song listened App language Lyrics provider priority Drag to reorder providers by preference. Higher position -> higher priority. Configure proxy Proxy username Proxy password Enable authentication Use details instead of state Show song title prominently instead of artist names Enable similar content Automatically add more similar songs when the end of the queue is reached Persistent shuffle Keep shuffle enabled when starting new songs or playlists Remember shuffle and repeat Remember shuffle and repeat mode when restarting the app %d%% Import a \"m3u\" playlists Import a "csv" playlists Note: Adding local songs to synced/remote playlists is unsupported. Any other combination is valid Export playlist Export as CSV Export as M3U Playlist exported successfully Failed to export playlist Share Save to Documents Auto download on like Automatically download songs when you like them Mini player swipe sensitivity %1$d%% Are you sure you want to clear all cached songs? Are you sure you want to clear all cached image? Are you sure you want to clear all downloads? Disable Not logged in to YouTube Open supported links Couldn\'t open app settings Release notes Changelog No changelogs available https://github.com/MetrolistGroup/Metrolist/releases View on GitHub Current version Version: %s Update settings Check for updates Checking for updates… Latest: %s Check for updates Hide changelog View changelog Failed to check for updates: %s All time Past 24 hours Past week Past month Past year My Top list length History duration Information Description Views Likes Dislikes Subscribe Subscribed 1 second %d seconds Set as default Sleep timer default set to %d min Enable automatic sleep timer Enables the sleep timer automatically with the default value by a custom time Set a custom day and time when the sleep timer should automatically activate Repeat Daily Monday to Friday Weekdays / Weekends Weekends (Sat–Sun) Custom Start time End time Monday Tuesday Wednesday Thursday Friday Saturday Sunday Stop at end of current song Stops the sleep timer at the end of the current playing song Fade out Fades out the volume in the final minute Alarm Enable alarm Alarm time Playlist Select playlist No playlists found %d song %d songs Selected Choose which playlist the alarm should play. Add alarm No alarms yet. Add one to start scheduled playback. Disabled Random In order Next: %s New alarm Edit alarm Save Time: %s Delete alarm Another alarm already exists at this time. If both trigger together, only one may effectively play. Play random song from playlist Next trigger Not scheduled Allow exact alarms Exact alarm permission improves reliability when the app is in the background. Battery optimization Disable battery optimization for better alarm reliability in the background. Disable load more when repeat all Don\'t auto load more songs and similar content when repeat all mode is enabled Pause music when media is muted Resume on Bluetooth connect Keep screen on when player is expanded Crossfade Crossfade between songs Crossfade duration Disable for gapless albums Don\'t crossfade if the album is gapless Beta Feature Crossfade is a new feature and may have bugs. If you experience any issues, please report them.\n\nThis feature disables audio offload due to technical limitations. Cyrillic Romanization Lyrics romanization Romanize Japanese lyrics Romanize Korean lyrics Romanize Chinese lyrics Romanize Hindi lyrics Romanize Punjabi lyrics Romanize Russian lyrics Romanize Ukrainian lyrics Romanize Belarusian lyrics Romanize Kyrgyz lyrics Romanize Serbian lyrics Romanize Bulgarian lyrics Detect language line by line The Cyrillic language will be detected line by line instead of the entire song. Are you sure? This is a hit or miss experimental feature. \n\nBy default, language is determined from the whole song, but with this option on, it will be determined line by line instead. This will allow multi-language songs to work BUT the language might not always be correct (for example if there is a Ukrainian lyric that doesn\'t contain any Ukrainian-specific letters, it might be romanized as Russian instead). \n\nIf you do not have issues, it is recommended to keep this option off. Romanize current track Lyrics Offset Interface Privacy & Security Player & Content Storage & Data System & About Updater Automatically check for updates Enable update notifications Update available App updates Notifications about new versions Enable offload Use the offload audio path for audio playback. Disabling this may increase power usage but can be useful if you experience issues with audio playback or post processing Disabled because Crossfade is active Google Cast Enable casting audio to Chromecast and other Cast-enabled devices Romanize Macedonian lyrics Show romanized lyrics as main Integrations Username Password Last.fm Integration Enable scrobbling Send Now Playing Send Likes/Unlikes Love/Unlove songs in Last.fm when they are Liked/Unliked in Metrolist Logging in… Scrobbling Configuration Scrobble songs longer than Scrobble delay percent Scrobble delay minutes Hide video songs Hide YouTube Shorts View the song\'s information Change the title or artist Create a station based on this item Add to the top of your queue Add to the bottom of your queue Save to your library Make available for offline playback Add to one of your playlists Fetch the latest metadata from YouTube Music Share a link to this item Permanently remove this item Change the song\'s tempo and pitch Adjust the audio equalizer Enable dynamic icon Mini-player Pure black mini-player Hold on! You\'ve chosen a cache size limit smaller than what the app is currently using (%1$s). If you continue, the app may remove some cached %2$s to match the new limit. Proceed anyway? Continue Word-by-word animation style None Fade Glow Slide Karaoke Apple Music Lyrics text size Lyrics line spacing Album art for %s You\'ve listened to unique albums Your top album is Your personal playlist is ready Your top 5 albums You\'ve listened to this album for %d minutes %d minutes No data Your top artists of the year %d minutes Your top songs of the year Album art Your top artist of the year is Top artist image You\'ve listened to them for %d minutes Your most played song is You\'ve listened for %d minutes You listened to unique artists You listened to unique songs METROLIST it\'s time to see what you\'ve been listening to let\'s go! Metrolist Logo 2025 YOUR WRAPPED IS READY! Time to see what you loved this year. Thank you for listening Special thanks to MO Agamy for creating Metrolist Close wrapped Your %s Wrapped Create playlist Playlist saved %d Profile %d Profiles Equalizer No equalizer profiles Import Profile System Equalizer Disabled %d band %d bands Delete Profile Are you sure you want to delete "%1$s"? This action cannot be undone. Could not read file Failed to open file: %1$s Import Error Error Failed to apply EQ profile: %1$s Found in Settings > Content plays Casting to %s Progress %s%% Listening to Metrolist Open Failed to create image: %s Copied Title Copied Artist Error playing Failed to parse proxy url. Playback failed Failed to save episode Failed to remove episode Failed to subscribe to podcast Failed to unsubscribe from podcast View Channel Album art No song playing Tap to open Metrolist Previous Play/Pause Next Like No song playing Tap to open Metrolist Music player widget with playback controls Circular music widget with play and like controls Music Player Turntable Music Recognizer Identify songs playing around you directly from your home screen Tap to identify song Listening… Identifying… No match found. Try again Recognition failed An error occurred. Please try again Unknown song Unknown artist Identify song Music Recognition Shows a notification while identifying a song from the widget Recording audio to identify the song… Together Listen Together Server URL Choose server Custom server Use custom server Username Connected Reconnecting… Disconnected Connecting… Connection error Create room Create a room and share the code with friends Join room Room code You are the host You are a guest Mute Unmute Join requests View logs Debug connection and messages Connection logs No logs yet Auto-approve join requests Automatically approve join requests instead of reviewing them manually Auto-approve song suggestions Automatically approve and queue song suggestions from guests Sync host volume Guests follow the host volume level Listen Together in top bar Show Listen Together in top app bar instead of navigation bar Listen to music with your friends in real-time. Create a room to be the host or join an existing room with a code. Note: You may get disconnected if you create a room while no music is playing and then switch to another app. Listen Together is not configured. Please set up the server URL in Settings → Integrations → Listen Together. %1$s requested %2$s Suggestion sent to host! %1$s wants to join the room Listen Together Notifications for Listen Together events Room created: %s Cannot edit username while in a room Waiting for approval from host Invalid room code Join request denied Join existing room Room code Leave room Join Create Joining room %s… Creating room… Connect Disconnect Create Join Approve Reject Clear Copy Copy all lyrics Change Copied to clipboard Not set Hosting room In room Pending requests Pending suggestions Suggest to host Kick Host You Connected users Enter username Enter room code Configure server, username, and more Username is required. Resync Copy code Remove this person from the session Permanently Block Block this person\'s join requests and hide their suggestions Transfer Ownership Make this person the host of the room Manage User Blocked Users %d user(s) blocked No blocked users Unblock User blocked by host AI Lyrics Translation Translating lyrics... Lyrics translated Provider Base URL API Key Model Translation Mode Target Language API Credentials Translation Translate meaning to target language Transcription Convert pronunciation to target script API Key Required API key is required No lyrics to translate Lyrics are empty Target language is required Unexpected translation result Unknown error occurred Translation failed Get API Keys Visit https://openrouter.ai for free and paid models Visit https://platform.openai.com/api-keys Visit https://console.anthropic.com/settings/keys Visit https://aistudio.google.com/apikey Visit https://perplexity.ai/settings/api Visit https://console.x.ai Visit https://deepl.com/pro-api for free and paid keys Visit https://console.mistral.ai/api-keys Formality Default More Formal Less Formal System Prompt Custom instructions sent to the AI model. Leave empty to use the default prompt. Use {lineCount} as a placeholder for the number of lines to translate. Default Reset to Default App Crashed An unexpected error occurred. Please share the crash report to help us fix the issue. Share Logs Share crash report Metrolist Crash Report Close No crash log available Dynamic Crimson Rose Purple Deep Purple Indigo Blue Sky Blue Cyan Teal Green Light Green Lime Yellow Amber Orange Deep Orange Brown Grey Blue Grey Back Pure Black mode Light mode Dark mode System mode %1$s palette Play all Enable high refresh rate Force the display to run at the highest supported refresh rate (e.g. 120Hz) Recognize Music Tap to recognize Listening… Processing… No match found Recognition error Try again Recognition History Clear recognition history Are you sure you want to clear all recognition history? Delete from history Re-listen Play on Metrolist Recognize Music Music Recognition Shows a notification while identifying a song from Quick Settings Listening for music... Identifying... No match found Recognition failed Listen on Metrolist Map CSV Columns First row is header Artist Name Column Song Title Column YouTube URL Column (Optional) Continue Importing CSV Importing Playlist Recently Converted Col %d Status Online Idle Do Not Disturb Buttons Button 1 Button 2 Login successful! This feature uses the KizzyRPC library to connect to Discord\'s Gateway and set your Rich Presence status. While no known account suspensions have occurred from similar usage, this method is not officially supported by Discord and may be considered a Terms of Service violation. Your token is extracted locally and never sent to third-party servers. Proceed at your own discretion. Activity type Playing Listening Watching Competing Variables: {song_name}, {artist_name}, {album_name} Rich Presence Preview Presence Sign in with Discord to share what you\'re listening to Playing Metrolist Watching Metrolist Competing in Metrolist Activity name Custom name for the activity (leave empty for default) Advanced mode Show additional customization options for Rich Presence Speed dial Pin to Speed dial Unpin from Speed dial Randomize Home Screen Order Randomly reorder home screen sections with weighted priorities Sounds like %1$s Because you listen to %1$s Similar to %1$s Based on %1$s For fans of %1$s From the community Keep library data? Do you want to keep your playlists and library data? Downloaded songs will be kept regardless. Keep Clear Lead Developer Collaborator Collaborators GNU General Public License v3.0 Free, open-source software. You may use, study, share and improve it. Discord Server Telegram Channel Website Instagram GitHub View Repository %1$s • %2$s Like what I do? Buy me a coffee Community & Info METROLIST Wanna play their favorite song? Yeah This project stands with Palestine 🇵🇸 Podcasts View podcast Podcast Channels Latest Episodes Your Shows New Episodes Episodes for Later Save for later Add to your Episodes for Later playlist Remove from saved Save podcast to library %d episode %d episodes Episodes Profiles Channels Auto playlist Downloaded episodes No subscribed channels No downloaded episodes %d channel %d channels Restore backup? This will restore your app data from the backup. You will need to log in again after restore. The following account will be signed out: Restore Checking for previous account… No account found Upload songs Uploading… %1$d of %2$d Upload complete Upload failed File too large (max 300MB) Unsupported format. Use mp3, m4a, wma, flac, or ogg Delete uploaded song Are you sure you want to delete this uploaded song? This cannot be undone. Uploaded song deleted Failed to delete uploaded song Delete uploaded songs Are you sure you want to delete %1$d uploaded songs? This cannot be undone. Deleted %1$d songs Deleting… Android Auto Visible sections Tap a section to enable or disable it.\nLong press the drag handle to reorder. The first section will be shown by default. Show YouTube suggested playlists Display YouTube Music recommended playlists inside the Playlists section when browsing with Android Auto Quick-add destination Select the playlist where songs will be saved when using the quick-add button in Android Auto Not set Added to playlist No playlist selected. Choose one in Android Auto settings. Time Transfer WARNING: It is not possible to revert this action once it is completed. A backup file should be created before proceeding. You can only target songs with at least one registered event/play. Source song Target song Listen time: Convert Type more to narrow results (%d more) ================================================ FILE: app/src/main/res/values/strings.xml ================================================ Home Songs Artists Albums Playlists %d selected History Stats Mood and Genres Account Quick picks Listen to songs to generate your quick picks Forgotten favorites Keep listening Your YouTube playlists Similar to New release albums Today Yesterday This week Last week Most played songs Most played artists Most played albums Search Search YouTube Music… Search library… Library Liked Downloaded All Songs Videos Albums Artists Playlists Community playlists Featured playlists Bookmarked No results found Library songs will show up here Library artists will show up here Library albums will show up here Your playlists will show up here From your library Other versions Liked songs Downloaded songs The playlist is empty Do you really want to remove all \"%s\" playlist songs from the Downloaded Songs storage? Do you really want to delete the playlist \"%s\"? Retry Radio Shuffle Reset Details Edit Start radio Play Pause Play next Add to queue Add to library Add all to library Remove from library Remove all from library Download Downloading Remove download Import playlist Add to playlist View artist View album Refetch Share Delete Remove from history Remove from playlist Remove from queue Search online Sync Advanced Tempo and Pitch Date added Name Artist Year Song count Length Play time Custom order Media id MIME type Codecs Bitrate Sample rate Loudness Volume File size Unknown Copied to clipboard Edit lyrics Search lyrics Edit song Song title Song artists Song title cannot be empty. Song artist cannot be empty. Save Choose playlist Edit playlist Create playlist Playlist name Playlist name cannot be empty. Edit artist Artist name Artist name cannot be empty. Duplicates Skip duplicates Add anyway The song is already in your playlist %d songs are already in your playlist %d song %d songs %d artist %d artists %d album %d albums %d playlist %d playlists %d week %d weeks %d month %d months %d year %d years Playlist imported Removed \"%s\" from playlist Playlist synced Undo Lyrics not found Sleep timer End of song 1 minute %d minutes No stream available No network connection Timeout Unknown error Like Like all Remove like Remove all likes Shuffle on Shuffle off Repeat mode off Repeat current song Repeat queue All songs Searched songs Music Player Settings Appearance Theme Customize your app theme Theme & Colors Theme Mode Color Palette Use system colors Enable dynamic theme Dark theme On Off Follow system Pure black Customize navigation tabs Player Player text alignment Lyrics text position Sided Left Center Right Player slider style Default Squiggly Misc Default open tab Grid cell size Small Big Content Log out Log in Login Not logged in Login failed Default content language Default content country System default Enable proxy Proxy type Proxy URL Restart to take effect Player and audio Audio quality Auto High Low Queue Persistent queue Restore your last queue when the app starts Auto load more songs Automatically add more songs when the end of the queue is reached, if possible Skip silence Audio normalization Auto skip to next song when error occurs Ensure your continuous playback experience Stop music on task clear Equalizer Storage Cache Image Cache Song Cache Max cache size Unlimited Clear all downloads Max image cache size Clear image cache Max song cache size Clear song cache %s used Privacy Listen history Pause listen history Clear listen history Are you sure you want to clear all listen history? Search history Pause search history Clear search history Are you sure you want to clear all search history? Use login for browsing content This can influence what content you see and for example shows premium-only albums if you are logged in with a Premium account Disable screenshot When this option is on, screenshots and the app\'s view in Recents are disabled. Enable LrcLib lyrics provider Enable KuGou lyrics provider Hide explicit content Backup and restore Backup Restore Imported playlist Backup created successfully Couldn\'t create backup Failed to restore backup Discord Integration Metrolist uses the KizzyRPC library to set your Discord account\'s status. This involves using the Discord Gateway connection, which may be considered a violation of Discord\'s TOS. However, there are no known cases of user accounts being suspended for this reason. Use at your own risk.\n\Metrolist will only extract your token, and everything else is stored locally. Dismiss Options Preview Enable Rich Presence About App version New version available Translation Models Clear translation models ================================================ FILE: app/src/main/res/values/styles.xml ================================================ ================================================ FILE: app/src/main/res/values/values.xml ================================================ ================================================ FILE: app/src/main/res/values/widget_colors.xml ================================================ #EADDFF #21005D #FFD8E4 #31111D @color/widget_primary_container @color/widget_on_primary_container #7965AF #994F378B #664F378B #334F378B #1A4F378B ================================================ FILE: app/src/main/res/values-ar/metrolist_strings.xml ================================================ للخلف غلاف الألبوم أفضل مقاطع الفيديو الموسيقيه الأكثر رواجًا أسابيع شهور سنين مستمر الأغاني المفضلة التحميلات الأعلي استماع الأغاني المخزنة مؤقتًا مزامنة قائمة التشغيل المزامنة غير مفعلة ملاحظة: يسمح هذا بالمزامنة مع YouTube Music. لا يمكن تغيير هذا لاحقًا. إزالة من التخزين المؤقت نسخ الرابط تحديد الكل الإعجاب بالكل إلغاء الإعجاب بالكل تاريخ التحديث موجود بالفعل في قائمة التشغيل: لا مرات %d مرة %d مرتان %d مرات %d مرة %d مرة لا ثوانٍ %d ثانية %d ثانيتان %d ثوانٍ %d ثانية %d ثانية نمط خلفية المشغل ألوان متدرجة ضبابي اتباع السيم ألوان أزرار المشغل افتراضي تمكين التمرير لتغيير الأغنية مرر الأغنية إلى اليسار لإضافتها إلى قائمة الانتظار أو إلى اليمين لتشغيلها بعد الأغنية الحالية تغيير كلمات الأغنية عند النقر عليها نحيل شريط تنقل سفلي رفيع قوائم التشغيل التلقائيه إظهار قائمة \"الاعجابات\" إظهار قائمة \"التحميلات\" إظهار قائمة \"الأعلى أستماع\" إظهار قائمة \"الأغاني المخزنة مؤقتًا\' جاري إنشاء الصورة انتظر من فضلك إلغاء شارك كلمات الأغاني المشاركة كنص المشاركة كصورة الحد الأقصى للاختيار شارك العناصر المحدده تخصيص الألوان لون النص لون النص الثانوي لون الخلفية تسجيل الدخول باستخدام token اضغط لعرض ال Token اضغط مرة أخرى للنسخ أو التعديل هذه طريقة تسجيل دخول متقدمة. كبديل للبوابة الإلكترونية، يمكنك إدخال أو تحديث رمز تسجيل الدخول الخاص بك هنا مباشرة. على سبيل المثال، يمكن أن يسرع ذلك عملية تسجيل الدخول على أجهزة متعددة. يرجى ملاحظة أن أي صيغة رمز غير صالحة لا يستطيع التطبيق تحليلها لن يتم قبولها عام الوكيل تغيير الشريحة الافتراضية للمكتبة ضبط الاختيارات السريعة بناءً على آخر أغنية تم الاستماع إليها لغة التطبيق تفعيل المحتوى المشابه إضافة المزيد من الأغاني المشابهة تلقائيًا عند الوصول إلى نهاية قائمة الانتظار %d%% التنزيل أوتوماتيكي عند الإعجاب تنزيل الأغاني تلقائيًا عند الاعجاب بها هل أنت متأكد أنك تريد مسح كافة الأغاني المخزنة مؤقتًا؟ هل أنت متأكد أنك تريد مسح كافة التنزيلات؟ الكلمات لم يتم تسجيل الدخول إلى يوتيوب فتح الروابط المدعومة افتراضيًا لم يتمكن التطبيق من فتح إعدادات التطبيق طوال الوقت آخر 24 ساعة الأسبوع الماضي الشهر الماضي السنة الماضية طول قائمة الأغاني المفضلة الخاصة بي مدة التاريخ المعلومات الوصف المشاهدات الإعجابات الغير محبوب محلي مزامن تم نسخ الرابط محتوى مشابه المخططات ملاحظات الاصدار الجديد تمرير كلمات الأغاني تلقائياً ملاحظة: إضافة الأغاني المحلية إلى قوائم التشغيل المتزامنة / قوائم التشغيل عن بعد غير مدعومة. أي نوع آخر منها صالح استيراد قوائم التشغيل من نوع \"csv\" استيراد قوائم التشغيل من نوع \"m3u\" كلمات الأغاني اليابانيه الرومانيه كلمات الأغاني الكوريه الرومانيه المزامنة التلقائية مع الحساب المزيد من المحتوي تصميم المشغل الجديد حساسية السحب الخاص بالمشغل الصغير هل أنت متأكد أنك تريد مسح كافة الصور المخزنة مؤقتًا؟ تعطيل %1$d%% اشتراك مشترك تصميم جديد للمشغل المصغر المُشغل الأن +%1$d ثواني للأمام - %1$d ثواني للخلف التقدم التدريجي إذا مُكنت أضف 5 ثوان إضافية بشكل تدريجي على كل مطاردة تعطيل تحميل المزيد عند تفعيل تكرار الكل لا يتم تحميل المزيد من الأغاني والمحتوى المماثل تلقائيًا عند تمكين وضع تكرار الكل إغلاق إخفاء صورة الأغنيه استبدال غلاف الألبوم بشعار التطبيق في المشغل الواجهة الخصوصية والأمان المشغل و المحتوي التخزين و البيانات ‫النظام والمعلومات بدء تشغيل الراديو تكوين الوكيل اسم مستخدم الوكيل كلمة سر الوكيل تمكين المصادقة إعدادات الرومنة الكتابة بالحروف اللاتينية رومنة كلمات الأغاني كلمات الأغاني الروسية الرومانية كلمات الأغاني الأوكرانية الرومانية كلمات الأغاني البيلاروسية بالرومانية كلمات أغنية Romanize Kyrgyz كلمات الأغاني الصربية الرومانية كلمات الأغاني البلغارية الرومانية تجريبي: اكتشاف اللغة سطرًا بسطر سيتم اكتشاف اللغة السيريلية سطرًا بسطر بدلاً من الأغنية بأكملها. هل أنت متأكد؟ بالنسبة لميزة التجربة هذه، إما أن تنجح أو تفشل.\n\nبشكل افتراضي، يتم تحديد اللغة من خلال الأغنية بأكملها، ولكن عند تفعيل هذا الخيار، سيتم تحديد اللغة سطراً تلو الآخر. وهذا سيسمح للأغاني متعددة اللغات بالعمل، ولكن قد لا تكون اللغة صحيحة دائماً (على سبيل المثال، إذا كانت هناك كلمات أوكرانية لا تحتوي على أي أحرف خاصة بالأوكرانية، فقد يتم تحويلها إلى الروسية بدلاً من ذلك).\n\nإذا لم تواجه أي مشاكل، يُوصى بإبقاء هذا الخيار معطلاً. كتابة المسار الحالي بالرومانية تبديل غلاف القائمة ملاحظة: يجب ربط حسابك برقم هاتف والتحقق منه على YouTube Music لتغيير غلاف القائمة. بعد تحديد الصورة، يرجى الانتظار قليلاً حتى يظهر الغلاف الجديد في قائمتك. اختر من المكتبة إزالة الصورة المخصصة تمكين التفريغ استخدم مسار الصوت غير المحمّل لتشغيل الصوت. قد يؤدي تعطيل هذا إلى زيادة استخدام الطاقة، ولكن قد يكون مفيدًا إذا واجهت مشكلات في تشغيل الصوت أو المعالجة اللاحقة المرفوعه المرفوعه إظهار قائمة التشغيل \"المرفوعه\" استخدم التفاصيل بدلاً من الحالة إظهار عنوان الأغنية بشكل بارز بدلاً من أسماء الفنانين التحديثات التحقق تلقائيًا من التحديثات تمكين إشعارات التحديث التحديث متاح تحديثات التطبيق إشعارات حول الإصدارات الجديدة كلمات أغنية رومانية مقدونية التكاملات اسم المستخدم كلمة المرور Last.fm تمكين سكروبلينج إرسال المُشغله حاليا تكوين سكروبلينج أغاني سكرابل أطول من نسبة تأخير سكرابل دقائق تأخير سكرابل مرر الأغنية لإزالتها من قائمة التشغيل عندما تعجبك أغنية أو تلغي إعجابك بها في Metrolist سيتم تلقائيًا وضع علامة (أحببت) أو (غير محبوب) على نفس الأغنية في Last.fm إرسال الإعجابات/إلغاء الإعجابات كلمات الأغاني الصينية بالحروف اللاتينية إخفاء أغاني الفيديو جوجل كاست تمكين بث الصوت إلى كروم كاست والأجهزة الأخرى التي تدعم البث اللون الأساسي إعادة المزامنة اطلع علي معلومات الأغنية غير العنوان أو الفنان انشئ محطة بناء علي هذا العنصر أضف إلي أعلي قائمة الانتظار الخاصة بك أضف إلي أسفل قائمة الانتظار الخاصة بك احفظ في مكتبتك اجعلها متاحة للتشغيل دون اتصال بالإنترنت أضفها إلي إحدي قوائم التشغيل الخاصة بك ‍ استخراج أحداث البيانات الوصفية من يوتيوب ميزوك شارك رابط هذا العنصر قم بإزالة هذا العنصر نهائيا غير ايقاع الأغنية ودرجة صوتها اضبط معادل الصوت تفعيل الأيقونات الديناميكية مشغل مصغر مشغل صغير أسود نقي انتظر! لقد اخترت حدًا لحجم ذاكرة التخزين المؤقت أصغر مما يستخدمه التطبيق حاليًا ( %1$s ). إذا تابعت، فقد يقوم التطبيق بإزالة بعض البيانات المخزنة مؤقتًا (%2$s) لتتوافق مع الحد الجديد. هل تريد المتابعة على أي حال؟ متابعة اللون الثانوي جارٍ تسجيل الدخول… وصف القائمة قم بإزالة جميع الأغاني التي تم تنزيلها من قائمة التشغيل هذه عملية التنزيل جارية شارك قائمة التشغيل هذه مع الآخرين قم بإزالة قائمة التشغيل هذه نهائياً مزامنة قائمة التشغيل مع YT Music تفعيل كلمات الأغاني الأفضل كلمات متزامنة مقطعيًا لأي أغنية للكاريوكي أسلوب الرسوم المتحركة للكلمات كلمة بكلمة لا تأثير تلاشي الاشعاع انزلاق كاريوكي أبل ميوزك حجم نص كلمات الأغنية تباعد أسطر الكلمات قم بتشغيل قائمة التشغيل/الألبوم عشوائياً أولاً عند تشغيل الأغاني عشوائياً، قم بتشغيل جميع الأغاني من قائمة التشغيل/الألبوم الأصلي أولاً، ثم المحتوى المشابه عرض البطاقة المغلفة غلاف ألبوم لـ %s استمعت إلى ألبومات فريدة ألبومك المفضل هو قائمة التشغيل الشخصية الخاصة بك جاهزة أفضل 5 ألبومات لديك لقد استمعت إلى هذا الألبوم لمدة %d دقيقة %d دقيقة لا توجد بيانات أفضل فناني العام %d دقيقة أفضل أغاني السنة صورة الألبوم فنانك المفضل لهذا العام هو انت استمعت إلى فنانون فريدون انت استمعت إلى أغاني فريدة حان الوقت لمعرفة ما كنت تستمع إليه هيا بنا! شعار متروليست 2025 ملخصك السنوي جاهز! حان الوقت لمعرفة ما أحببته في هذا السنة. شكرًا لاستماعكم شكر خاص لمصطفي العجمي لإنشاء متروليست اغلاق ملخص السنة صورة الفنان الرئيسي لقد استمعت إليهم لمدة %d دقيقة أغنيتك الأكثر استماعاً هي لقد استمعت لمدة %d دقيقة METROLIST ملخصك السنوي لـ %s إنشاء قائمة تشغيل تم حفظ قائمة التشغيل التحويل إلى %s التقدم %s%% الاستماع إلى متروليست فتح فشل إنشاء الصورة: %s فشل تحليل عنوان URL للوكيل. العنوان منسوخ الفنان منسوخ حدث خطأ أثناء التشغيل تفعيل تأثير الكلمات المتوهجة أضف تأثيرات متحركة متوهجة وارتدادية إلى كلمات الأغاني المتحركه متموج %d ملف تعريف %d ملف تعريف %d ملفا تعريف %d ملفات تعريف %d ملف تعريف %d ملف تعريف مُعادل لا توجد ملفات تعريف للمعادل ملف تعريف الاستيراد معطّل %d نطاق %d نطاق %d نطاقين %d نطاقات %d نطاقًا %d نطاق حذف الملف الشخصي هل أنت متأكد من رغبتك في حذف %1$s؟ لا يمكن التراجع عن هذا الإجراء. تعذر قراءة الملف فشل فتح الملف: %1$s خطأ في الاستيراد أوقف تشغيل الموسيقى مؤقتًا عند كتم صوت الوسائط تفعيل كلمات أغاني SimpMusic كلمات الأغاني المتزامنة تلقائيًا\nمن Musixmatch ونص YouTube حول عرض المزيد عرض القليل صفحة الفنان عرض وصف الفنان عرض عدد المشتركين عدد المستمعين الشهريين قم بتخطي الأجزاء الصامتة من الأغاني تجاوز الصمت فوراً قم بتخطي اللحظات الصامتة بدلاً من تسريع التشغيل تذكر خلط الاغاني و تكرار الأغنية تذكر وضع خلط الاغاني و تكرار الاغنية بعد إعادة تشغيل التطبيق ازاحة كلمات الأغنية معادل النظام صورة الالبوم لا اغنية حاليا اضغط لفتح تطبيق متروليست السابق تشغيل\\ايقاف مؤقت التالي اعجاب أداة تشغيل الموسيقى مع عناصر تحكم التشغيل أداة موسيقى دائرية تحتوي على أزرار التشغيل والإعجاب التشغيل العشوائي المستمر الإبقاء على التشغيل العشوائي مفعّلًا عند بدء أغانٍي أو قوائم تشغيل جديدة خطأ فشل تطبيق ملف تعريف المعادل الصوتي : EQ %1$s فشل التشغيل اقتصاص صورة غلاف الألبوم فرض نسبة عرض إلى ارتفاع مربعة عن طريق اقتصاص صور معاينة الفيديو إبقاء الشاشة مفتوحة عند توسيع مشغل الفيديو استمعوا معًا عنوان URL للخادم اسم المستخدم متصل إعادة الاتصال… غير متصل جارٍ الاتصال… خطأ في الاتصال إنشاء غرفة أنشئ غرفة وشارك الكود مع الأصدقاء الانضمام إلى الغرفة رمز الغرفة أنت المضيف أنت ضيف كتم الصوت إلغاء كتم الصوت طلبات الانضمام عرض السجلات تصحيح اتصال و\nرسائل سجلات الاتصال لا توجد سجلات حتى الآن استمع إلى الموسيقى مع أصدقائك في الوقت الفعلي. أنشئ غرفة لتكون المضيف أو انضم إلى غرفة موجودة باستخدام رمز ملاحظة: قد يتم قطع اتصالك إذا أنشأت غرفة أثناء عدم تشغيل أي موسيقى ثم انتقلت إلى تطبيق آخر. لم يتم تكوين ميزة \"الاستماع معًا\". يُرجى إعداد عنوان URL للخادم في الإعدادات ← التكاملات ← الاستماع معًا. %1$s طلب %2$s تم إرسال اقتراح إلى المضيف! يريد %1$s الانضمام إلى الغرفة استمعوا معًا إشعارات الاستماع أحداث معًا تم إنشاء الغرفة: %s لا يمكن تعديل اسم المستخدم أثناء التواجد في غرفة في انتظار موافقة المضيف رمز غرفة غير صالح تم رفض طلب الانضمام الانضمام إلى غرفة موجودة رمز الغرفة مغادرة الغرفة انضم إنشاء الانضمام إلى الغرفة %s… إنشاء غرفة… اتصال قطع الاتصال إنشاء انضم أوافق رفض واضح نسخ نُسخ إلى الحافظة غير محدد غرفة الاستضافة في الغرفة الطلبات المعلقة الاقتراحات المعلقة اقتراح الاستضافة طرد مضيف انت المستخدمون المتصلون أدخل اسم المستخدم اسم المستخدم مطلوب. إعادة مزامنة تعطل التطبيق حدث خطأ غير متوقع يرجى مشاركة تقرير العطل لمساعدتنا في حل المشكلة. مشاركة السجلات مشاركة تقرير التعطل تقرير تعطل تطبيق Metrolist إغلاق لا يوجد سجل أعطال متاح ديناميكي قرمزي روز بنفسجي بنفسجي داكن نيلي أزرق أزرق سماوي سماوي أزرق مخضر أخضر أخضر فاتح ليموني أصفر كهرماني برتقالي برتقالي داكن بني رمادي أزرق رمادي رجوع وضع الأسود النقي الوضع الفاتح الوضع الداكن وضع النظام لوحة ألوان %1$s لا يتم تشغيل أي أغنية اضغط لفتح Metrolist مشغل موسيقى القرص الدوار اختر خادمًا خادم مخصص استخدام خادم مخصص الموافقة التلقائية على طلبات الانضمام الموافقة التلقائية على طلبات الانضمام بدلاً من مراجعتها يدويًا مزامنة وحدة تخزين المضيف مستوى صوت الضيوف مطابق لمستوى صوت المضيف نسخ الكود إزالة هذا الشخص من الجلسة حظر دائم حظر طلبات انضمام هذا الشخص وإخفاء اقتراحاته نقل الملكية اجعل هذا الشخص مضيف الغرفة إدارة المستخدم المستخدمون المحظورون تم حظر %d مستخدم (مستخدمين) لا يوجد مستخدمون محظورون إلغاء الحظر تم حظر المستخدم من قبل المضيف التعرف على الموسيقى خانة رابط يوتيوب (اختياري) وضع الترجمة نموذج إعادة الاستماع ترجمة كلمات الأغاني... مفتاح API هل أنت متأكد من رغبتك في مسح جميع سجلات التعرف؟ لم يتم العثور على تطابق الموفر حذف من السجل خانة اسم الفنان جارٍ المعالجة… النص المكتوب اللغة المستهدفة مسح سجل التعرف ترجمة بيانات اعتماد واجهة برمجة التطبيقات ترجمة كلمات الأغاني باستخدام الذكاء الاصطناعي فشلت الترجمة تعيين خانات CSV معًا لا توجد كلمات للترجمة تمت ترجمة كلمات الأغنية خانة %d كلمات الأغنية فارغة خطأ في التعرف مفتاح API مطلوب حدث خطأ غير معروف فرض تشغيل الشاشة بأعلى معدل تحديث مدعوم (مثل 120 هرتز) اللغة المستهدفة مطلوبة الصف الأول هو عنوان حاول مرة أخرى انقر للتعرف سجل التعرف تكوين الخادم واسم المستخدم والمزيد تفعيل معدل التحديث العالي خانة عنوان الأغنية تم تحويله مؤخرًا استيراد ملف CSV تشغيل على Metrolist الاستماع… مفتاح API مطلوب نتيجة ترجمة غير متوقعة أدخل رمز الغرفة متابعة عنوان URL الأساسي تشغيل الكل تفعيل التلاشي المتقاطع انتقال تدريجي بين الأغاني مدة التلاشي التدريجي تعطيل للألبومات بدون فواصل لا تقم بالتلاشي التدريجي إذا كان الألبوم بدون فواصل ميزة تجريبية التلاشي التدريجي ميزة جديدة وقد تحتوي على أخطاء إذا واجهت أي مشاكل يُرجى الإبلاغ عنها هذه الميزة تُعطّل إخراج الصوت بسبب قيود تقنية معطل لأن التلاشي المتقاطع نشط إخفاء مقاطع يوتيوب القصيرة استمع معًا من شريط الأعلى عرض استمعوا معًا في شريط التطبيق العلوي بدلاً من شريط التنقل ثابت منع المسارات المكررة في قائمة الانتظار عند إضافة مسار إلى قائمة الانتظار قم بإزالته من موضعه السابق إذا كان موجودًا بالفعل استئناف الاتصال عبر البلوتوث كلمات الأغاني الهندية بالرومانية كلمات الأغاني البنجابية بالرومنة عرض كلمات الأغاني بالحروف اللاتينية كنص رئيسي ترجمة المعنى إلى اللغة المستهدفة تحويل النطق إلى النص المستهدف الحصول على مفاتيح API تفضل بزيارة https://openrouter.ai للحصول على نماذج مجانية ومدفوعة زيارة\nhttps://platform.openai.com/api-keys زيارة\nhttps://console.anthropic.com/setti ngs/keys زيارة\nhttps://aistudio.google.com/apikey زيارة\nhttps://perplexity.ai/settings/api تفضل بزيارة https://console.x.ai تفضل بزيارة https://deepl.com/pro-api للحصول على مفاتيح مجانية ومدفوعة رسمية افتراضي أكثر رسمية أقل رسمية الحالة أونلاين خامل ممنوع الإزعاج أزرار الزر 1 الزر 2 تم تسجيل الدخول بنجاح! تستخدم هذه الميزة مكتبة KizzyRPC للاتصال ببوابة ديسكورد وتعيين حالة حضورك الغني. على الرغم من عدم وجود حالات تعليق حسابات معروفة ناتجة عن استخدام مماثل، إلا أن هذه الطريقة غير مدعومة رسميًا من قبل ديسكورد وقد تُعتبر انتهاكًا لشروط الخدمة. يتم استخراج رمزك محليًا ولا يتم إرساله أبدًا إلى خوادم جهات خارجية. تابع على مسؤوليتك الخاصة. نوع النشاط جارٍ التشغيل الاستماع مشاهدة التنافس {song_name} → اسم الأغنية\n{artist_name} → اسم الفنان\n{album_name} → اسم الألبوم معاينة الحالة الغنية التواجد سجّل الدخول باستخدام Discord للمشاركة ما تستمع إليه تشغيل Metrolist مشاهدة Metrolist التنافس في Metrolist اسم النشاط اسم مخصص للنشاط (اتركه فارغًا للافتراضي) الوضع المتقدم عرض خيارات تخصيص إضافية لـ Rich Presence كثافة الشاشة إعادة التشغيل إعادة التشغيل مطلوبة سيسري تغيير كثافة الشاشة بعد إعادة تشغيل التطبيق.\nهل تريد إعادة التشغيل الآن؟ موجود في الإعدادات > المحتوى مرات التشغيل الاتصال السريع رقم التعريف الشخصي للاتصال السريع إلغاء التثبيت من الاتصال السريع ترتيب الشاشة الرئيسية عشوائيًا إعادة ترتيب أقسام الشاشة الرئيسية عشوائيًا حسب الأهمية صوته مثل %1$s لأنك تستمع إلى %1$s مشابه لـ %1$s بناءً على %1$s لمحبي %1$s ‌من قِبل المجتمع قاعدة بيانات لكلمات الأغاني المتزامنة تعتمد على مساهمات المجتمع يأخذ كلمات الأغاني من KuGou، وهي منصة موسيقى صينية شهيرة ملاحظة: ستظهر كلمات الأغاني من يوتيوب ميوزك تلقائيًا عندما لا تتوفر كلمات أخرى.\nعادةً لا تتم مزامنة كلمات الأغاني من يوتيوب ميوزك. تفعيل LyricsPlus كلمات متزامنة من مصادر متعددة اختيار الموفر اختر مزودي كلمات الأغاني المفعلين أولوية مزوّد كلمات الأغاني اسحب لإعادة ترتيب المزوّدين حسب التفضيل. كلما كان المزوّد في موضع أعلى كانت له أولوية أعلى. سجل التغييرات لا توجد سجلات تغييرات متاحة https://github.com/MetrolistGroup/Metrolist/releases ‌• عرض على GitHub الإصدار الحالي الإصدار: %s تحديث الإعدادات تحقق من وجود تحديثات جارٍ التحقق من وجود تحديثات… الأحدث: %s التحقق من وجود تحديثات إخفاء سجل التغييرات عرض سجل التغييرات فشل التحقق من وجود تحديثات: %s تعيين كافتراضي تم ضبط مؤقت النوم افتراضيًا على %d دقيقة فشل حفظ الحلقة فشل حذف الحلقة فشل الاشتراك في البودكاست فشل إلغاء الاشتراك في البودكاست الموافقة التلقائية على اقتراحات الأغاني الموافقة التلقائية على اقتراحات الأغاني من الضيوف ووضعها في قائمة الانتظار الاحتفاظ ببيانات المكتبة؟ هل تريد الاحتفاظ بقوائم التشغيل وبيانات المكتبة؟\nسيتم الاحتفاظ بالأغاني التي تم تنزيلها بغض النظر. احتفظ مسح مطور رئيسي استيراد قائمة التشغيل متعاون المتعاونون رخصة جنو العمومية الإصدار 3.0 برنامج مجاني ومفتوح المصدر يمكنك استخدامه ودراسته ومشاركته وتحسينه. خادم ديسكورد قناة تيليجرام موقع ويب انستغرام جيت هاب عرض المستودع %1$s • %2$s هل يعجبك ما أفعله؟ اشترِ لي قهوة المجتمع والمعلومات METROLIST هل تريد تشغيل أغنيتهم المفضلة؟ نعم هذا المشروع يقف مع فلسطين 🇵🇸 بودكاست عرض البودكاست قنوات البودكاست أحدث الحلقات عروضك حلقات جديدة الحلقات لاحقًا حفظ لوقت لاحق أضف إلى حلقاتك للمشاهدة لاحقًا قائمة التشغيل إزالة من المحفوظات حفظ البودكاست في المكتبة %d حلقة %d حلقة %d حلقتان %d حلقات %d حلقة %d حلقة استعادة النسخة الاحتياطية؟ سيؤدي هذا إلى استعادة بيانات تطبيقك من النسخة الاحتياطية. ستحتاج إلى تسجيل الدخول مرة أخرى بعد الاستعادة. سيتم تسجيل خروج الحساب التالي: استعادة جارٍ التحقق من الحساب السابق… لم يتم العثور على حساب التعرف على الموسيقى حدد الأغاني التي يتم تشغيلها من حولك مباشرة من شاشتك الرئيسية انقر لتحديد الأغنية جارٍ الاستماع… جارٍ تحديد… لم يتم العثور على تطابق. حاول مرة أخرى فشل التعرّف مرة أخرى حدث خطأ، يرجى المحاولة أغنية غير معروفة فنان مجهول تحديد الأغنية التعرف على الموسيقى يعرض إشعارًا أثناء تحديد أغنية من الأداة تسجيل الصوت لتحديد الأغنية… ================================================ FILE: app/src/main/res/values-ar/strings.xml ================================================ فنانون ألبومات السجل إحصائيات حساب إختيارات سريعة مفضلات مهملة قوائم أغانيك على YouTube تشبه ألبومات أصدرت حديثًا اليوم أمس الأسبوع الماضي الفنانون الأكثر استماعًا الألبومات الأكثر استماعًا ابحث في YouTube Music… ابحث في المكتبة… أعجبك تم تنزيلها جميع اغاني فنانون قوائم أغاني قوائم أغاني من مستخدمين قوائم أغاني مميزة محفوظ لم يتم العثور على نتائج سيظهر الفنانون هنا ستظهر الألبومات هنا قوائم أغانيك ستظهر هنا نسخ اخرى أغاني قوائم الأغاني الأوضاع والتصنيف استمع إلى الأغاني لتوليد قائمة السريعة ابحث تابع الاستماع الأسبوع الحالي الأغاني الأكثر استماعًا مكتبة ألبومات فيديوهات ستظهر أغاني المكتبة هنا من مكتبتك الرئيسية %d تم اختياره %d تم اختياره %d تم اختيارهم %d تم اختيارها %d تم اختيارها %d تم اختيارهم أغاني مفضلة أغاني تم تنزيلها قائمة الأغاني فارغة هل تريد حقًا إزالة جميع أغاني قائمة الأغاني \"%s\" من مساحة تخزين الأغاني التي تم تنزيلها؟ هل تريد حقًا إزالة قائمة الأغاني \"%s\"؟ أعد المحاولة راديو خلط إعادة إلى الوضع الأصلي تفاصيل عدل بدء الراديو شغل تشغيل التالي ضف إلى المكتبة ضف إلى قائمة الانتظار امسح من المكتبة مسح التنزيل استورد قائمة الأغاني ضف إلى قائمة الأغاني عرض الفنان عرض الألبوم أعد الإحضار شارك امسح امسح من التاريخ امسح من قائمة الانتظار ابحث على الإنترنت مزامنة سرعة الإيقاع والحدة الاسم الفنان الرقم التعريفي للوسيط نوع MIME حزم الترميز وفك الترميز معدل البتات معدل أخذ العينات عدد الأغاني مستوى الصوت غير معروف تم النسخ إلى الحافظة عدل الكلمات عدل الأغنية اسم الأغنية اسم الأغنية لا يمكن أن يكون فارغًا. احتفظ اختر قائمة أغاني عدل قائمة الأغاني أنشأ قائمة أغاني تخطي النسخ المكررة أضف على أي حال الأغنية موجودة بالفعل في قائمة أغانيك %d أغنية موجودة بالفعل في قائمة التشغيل الخاصة بك %d أغاني أغنية أغنيتان %d أغاني %d أغنية %d أغنية %d فنانون فنان فنانان %d فنانون %d فنان %d فنان %d قوائم أغاني قائمة أغاني قائمتا أغاني %d قوائم أغاني %d قائمة أغاني %d قائمة أغاني %d أسابيع أسبوع أسبوعان %d أسابيع %d أسبوع %d أسبوع %d شهور شهر شهران %d شهور %d شهر %d شهر %d أعوام عام عامان %d أعوام %d عام %d عام تم مسح \"%s\" من قائمة الأغاني تراجع لم يتم العثور على كلمات مؤقت النوم نفذ الوقت الخلط مفعل الخلط موقف وضع التكرار موقف كرر الأغنية الحالية كرر قائمة الانتظار كل الأغاني الأغاني التي بحثت عنها مشغل الموسيقى إعدادات مظهر سمة فعل المظهر الديناميكي الوضع المظلم مفعل متوقف اتبع النظام أسود نقي تخصيص نوافذ التنقل مشغل محاذاة نص المشغل موضع الكلمات جانبية يسار المنتصف يمين طراز شريط تمرير المشغل الافتراضي %d دقيقة %d دقيقة %d دقيقة %d دقائق %d دقيقة %d دقيقة متنوعات النافذة الافتراضية عند الفتح صغير حجم خلايا الشبكة غير مسجل الدخول لغة المحتوى الافتراضية فعل الوكيل نوع الوكيل رابط الوكيل أعد التشغيل للتفعيل عالي منخفض قائمة الانتظار تحميل تلقائي لمزيد من الأغاني تخطي الصمت تسوية الصوت انتقل تلقائيًا إلى الأغنية التالية عند حدوث خطأ أضمن تجربة التشغيل المستمر الخاصة بك إيقاف الموسيقى عند إيقاف تطبيقات الخلفية موازن الصوت أقصى مساحة لذاكرة التخزين المؤقتة غير محدود مسح كل التنزيلات أقصى مساحة لذاكرة تخزين الصور المؤقتة مسح ذاكرة تخزين الصور المؤقتة أقصى مساحة لذاكرة تخزين الأغاني المؤقتة مسح ذاكرة تخزين الأغاني المؤقتة %s تم تخزينه اوقف تأريخ الاستماع مؤقتًا مسح تاريخ الاستماع فعل موفر الكلمات LrcLib النسخ الاحتياطي والاستعادة استعادة قائمة أغاني مستوردة اخفي المحتوى الصريح تعذر إنشاء نسخة احتياطية التكامل مع Discord ضف الكل إلى المكتبة تنزيل امسح الكل من المكتبة يتم التنزيل ترتيب مخصص مدة التشغيل شدة الصوت حجم الملف ابحث عن الكلمات فنانون الأغنية فنان الأغنية لا يمكن أن يكون فارغًا. اسم قائمة الأغاني لا يمكن أن تكون فارغًا. اسم قائمة الأغاني اسم الفنان نسخ مكررة عدل الفنان اسم الفنان لا يمكن أن يكون فارغًا. امسح من قائمة الأغاني %d ألبومات ألبوم ألبومان %d ألبومات %d ألبوم %d ألبوم متقدم تاريخ الإضافة السنة المدة تمت مزامنة قائمة الأغاني تم استيراد قائمة الأغاني نهاية الأغنية لا يوجد اتصال بالشبكة لا يوجد تدفق متاح أعجبتني خطأ غير معروف أعجبني الكل مسح الإعجاب مسح كل الأعجابات متعرج المشغل والصوت تلقائي تسجيل الدخول كبير محتوى الإعدادات الافتراضية للنظام بلد المحتوى الافتراضية جودة الصوت قائمة انتظار ثابتة استعد قائمة انتظارك الأخيرة عند بدء تشغيل التطبيق ضف المزيد من الأغاني تلقائيًا عند الوصول إلى نهاية قائمة الانتظار، إن أمكن مساحة التخزين ذاكرة التخزين المؤقت ذاكرة تخزين الصور المؤقتة ذاكرة تخزين الأغاني المؤقتة هل أنت متأكد أنك تريد مسح كل سجل الاستماع؟ سجل البحث خصوصية تاريخ الاستماع اوقف سحل البحث مؤقتًا مسح سجل البحث فعل موفر الكلمات KuGou هل أنت متأكد أنك تريد مسح كل سجل البحث؟ تعطيل قابلية اخذ لقطة للشاشة نسخة احتياطيه تم إنشاء النسخة الاحتياطية بنجاح فشل في استعادة النسخة الاحتياطية خيارات معاينة Metrolist يستخدم مكتبة KizzyRPC لتعيين حالة حساب Discord الخاص بك. هذا يتضمن استخدام اتصال Discord Gateway، والذي قد يعتبر انتهاكًا لشروط خدمة Discord. مع ذلك، لا توجد حالات معروفة لتعطيل حسابات المستخدمين لهذا السبب. استخدم على مسؤوليتك الخاصة. \n \nسيقوم Metrolist فقط باستخراج رمز الوصول الخاص بك، وسيتم تخزين كل شيء آخر محليًا. صرف فعل Rich Presence نسخة جديدة متوفرة فشل تسجيل الدخول حول نسخة التطبيق نماذج الترجمة تسجيل الخروج مسح نماذج الترجمة عند تفعيل هذا الخيار، يتم تعطيل اخذ لقطات للشاشة وعدم اظهار محتوى التطبيق في التطبيقات الأخيرة. استخدم تسجيل الدخول لتصفح المحتوى يمكن أن يؤثر ذلك على المحتوى الذي تراه، فعلى سبيل المثال، يعرض الألبومات المخصصة للمشتركين المميزين فقط إذا كنت مسجلاً الدخول باستخدام حساب مميز تسجيل دخول ================================================ FILE: app/src/main/res/values-as/metrolist_strings.xml ================================================ স্থানীয় দূৰৱৰ্তী চাৰ্ট পিছলৈ এলবামৰ কভাৰ শীৰ্ষ মিউজিক ভিডিঅ\' ট্ৰেণ্ডিং সপ্তাহ মাহ বছৰ অবিৰত ভাল লাগিল ডাউনলোড কৰা হৈছে মোৰ টপ কেচ কৰা হৈছে আপলোড কৰা হৈছে আপলোড কৰা হৈছে প্লেলিষ্ট ছিঙ্ক কৰক ছিঙ্ক নিষ্ক্ৰিয় কৰা হৈছে বি:দ্ৰ: ইয়াৰ দ্বাৰা YouTube Music ৰ সৈতে ছিংকিং কৰিব পৰা যায়। এইটো পিছত সলনি হ\'ব নোৱাৰে। ছবি সৃষ্টি কৰা অনুগ্ৰহ কৰি অপেক্ষা কৰক বাতিল কৰক গীতৰ শাৰী শ্বেয়াৰ কৰক টেক্সট হিচাপে শ্বেয়াৰ কৰক ইমেজ হিচাপে শ্বেয়াৰ কৰক সৰ্বোচ্চ নিৰ্বাচন সীমা শ্বেয়াৰ নিৰ্বাচিত কৰা হৈছে ৰং কাষ্টমাইজ কৰক লিখনীৰ ৰং দ্বিতীয় টেষ্টৰ ৰং বেকগ্ৰাউণ্ড ৰং কেচৰ পৰা আঁতৰাওক বিষয় অধিক দেখুৱাওক কম দেখুৱাওক ================================================ FILE: app/src/main/res/values-az/metrolist_strings.xml ================================================ Daxili Server tərəfi Çartlar Geriyə Albom üzlüyü Yüksək reytinqli kliplər Trenddə olanlar Həftələr Aylar İllər Davamlı Sevimli Yüklənmiş Mənim ən sevimlilərim Keşə yüklənmiş Çalğı siyahısını sinxronizasiya et Sinxronizasiya qeyri-aktivdir Qeyd: Bu, YouTube Music ilə sinxronizasiya etməyə imkan verir. Bu sonradan dəyişdirilə bilməz. Şəkil yaradılır Zəhmət olmasa gözləyin Ləğv et Mahnı sözlərini paylaşın Mətn kimi paylaş Şəkil kimi paylaş Maksimum seçim limiti Seçilmişləri paylaş Rəngləri fərdiləşdirin Mətnin rəngi İkinci dərəcəli mətn rəngi Fon rəngi Keşdən sil Linki kopyala Hamısın seç Hamısını bəyən Hamısını bəyənmə Yenilənmə tarixi Link bufetdə kopyalandı Radio işə salınır İndi oynayır Mahnı sözləri Bağla Tətbiqin miniatürünü gizlədin Albom təsvirini pleyerdə tətbiq loqosu ilə əvəz edin Artıq calğı siyahısında olanlar: %d dəfə %d dəfələr %1$d saniyə irəli %1$d saniyə geriyə Proqressiv axtarış Aktivdirsə, hər axtarış atlamasında tədricən 5 əlavə saniyə əlavə edir Oxşar kontent ================================================ FILE: app/src/main/res/values-b+sr+Latn/metrolist_strings.xml ================================================ Lajkovi Lokalna Udaljena Liste Nazad Omot albuma Najgledaniji muzički spotovi U trendu Nedelje Meseci Godine Kontinuirano Lajkovano Moje najbolje Preuzeto Keširano Sinhronizuj plejlistu Sinhronizacija onemogućena Napomena: Ovo omogućava sinhronizaciju sa YouTube Music. Ovo se NE MOŽE promeniti kasnije. Generišem sliku Molimo sačekajte Otkaži Podeli tekst pesme Podeli kao tekst Podeli kao sliku Maksimalni broj izbora Podeli izabrano Izmeni boje Boja teksta Sekundarna boja teksta Boja pozadine Ukloni iz keša Kopiraj link Izaberi sve Obeleži sve kao omiljeno Poništi sve lajkove Datum ažuriran Link je kopiran Tekst pesme Već je u plejlisiti: Sličan sadržaj Stil pozadine plejera Prati temu Gradijent Zamućenje Boje dugmadi plejera Podrazumevano Omogući prevlačenje za promenu pesme Prevuci pesmu ulevo da je dodaš u red, ili udesno da je pustiš odmah posle Promeni tekst pesme na klik %d sekunda %d sekunde %d sekundi Pokretanje radija Trentuno pušteno Sakrij ikonicu plejera +%1$d sekundi unapred -%1$d sekundi unazad Napredna pretraga Poslato Poslato Preuzmi sve pesme za reprodukciju van mreže Obriši sve preuzete pesme iz ove plejliste Preuzimanje je u toku Podeli ovu plejlistu sa drugima Sinhronizuj plejlistu sa Youtube Music Zatvori Kada je aktivirano, dodaje 5 sekundi za svako preskakanje traženja Novi dizajn plejera Novi dizajn mini-plejera Primarna boja Tercijalna boja ================================================ FILE: app/src/main/res/values-b+sr+Latn/strings.xml ================================================ Početna Pesme Umetnici Albumi Plejliste %d izabran %d izabrana %d izabrano Istorija Statistike Raspoloženje i Žanrovi Račun Brzi izbori Odslušaj pesme kako bi generisao tvoje brze izbore Zaboravljeni favoriti Nastavi slušanje Tvoje YouTube plejliste Slično ko Novi albumi Danas Juče Ove nedelje Prošle nedelje Najslušanije pesme Najslušaniji izvođači Najslušaniji albumi Pretraži Pretraži YouTube Music… Preuzeto Sve Pesme Videi Albumi Izvođači Plejliste Plejliste zajednice Istaknute plejliste Obeleženo Nema rezultata Pesme biblioteke će se prikazati ovde Tvoje plejliste će se pojaviti ovde Iz tvoje biblioteke Druge verzije Preuzete pesme Plejlista je prazna Da li zaista želite da obrišete plejlistu \"%s\"? Probaj ponovo Radio Promešaj Ponovo pokreni Detalji Uredi Pokrenite radio Dodaj u sve biblioteke Ukloni iz biblioteke Ukloni iz svih biblioteka Preuzmi Preuzima se Ukloni preuzimanje Uvezi plejlistu Dodaj u plejlistu Pogledaj umetnika Pogledaj album Dodaj ponovo Podeli Izbriši Ukloni iz plejliste Ukloni iz reda Pretraži preko mreže Sinhronizuj Napredno Tempo i Visina tona Datum dodavanja Ime Umetnik Godina Broj pesama Specijalan red Id medija Stopa uzorkovanja Glasnina Veličina datoteke Nepoznato Kopirano u međuspremnik Izmeni tekst Pretraži tekstove Izmeni pesmu Naziv pesme Umetnik pesme Naslov pesme ne može biti prazan. Umetnik pesme ne može biti prazan. Ime plejliste ne može biti prazno. Izmeni umetnika Ime umetnika Ime umetnika ne može biti prazno. Duplikati Preskoči duplikate Dodaj svejedno Pesma je već na vašoj plejlisti %d pesme su već u vašoj plejlisti %d pesma %d pesme %d pesama %d izvođač %d izvođača %d izvođača %d album %d albuma %d albuma %d plejlista %d plejliste %d plejlisti %d nedelja %d nedelje %d nedelja %d mesec %d meseca %d meseci %d godina %d godine %d godina Plejlista uvezena Ukloni \"%s\" iz plejliste Vrati Sviđa mi se sve Ukloni sviđanje Ukloni sva sviđanja Mešanje uključeno Mešanje isključeno Režim ponavljanja je isključen Ponavljaj trenutnu pesmu Ponovi red Sve pesme Pretražene pesme Muzički Plejer Podešavanja Izgled Tema Uključi dinamičnu temu Tamna tema Uključeno Isključeno Prati sistem Čisto crna Izmeni kartice za navigaciju Plejer Poravnanje teksta plejera Pozicija teksta pesama Levo Centar Desno Izgled klizača plejera Podrazumevano Valovito Na strani Veličina ćelije mreže Malo Prijavi se Niste prijavljeni Podrazumevani jezik sadržaja Sistemski podrazumevano Omogući proksiju Tip proksija URL Proksija Plejer i zvuk Kvalitet zvuka Automatski Visoko Nisko Uporan red Obnovite svoj zadnji red kada se aplikacija ponovo pokrene Automatski učitajte više pesama Automatski dodajte više pesma kada se red završi, ako je moguće Preskoči tišinu Normalizacija zvuka Automatski preskoči do sledeće pesme kada dođe do greške Obezbedite svoje neprekidno iskustvo reprodukcije Zaustavite muziku kada se procesi obrišu Ekvalizator Maksimalna veličina keša pesama Obriši keš pesama %s korišteno Privatnost Istorija slušanja Obriši istoriju pretrage Da li ste sigurni da želite da obrišete svu istoriju pretrage? Pauziraj istoriju slušanja Obriši istoriju slušanja Da li ste sigurni da želite da obrišete svu istoriju slušanja? Istorija pretrage Pauziraj istoriju pretrage Onemogući slikanje ekrana Kada je opcija omogućena, slikanje zaslona i pregled aplikacije u Nedavnim je onemogućeno. Omogući LrcLib dobavljača teksta pesama Omogući KuGou dobavljača teksta Sakrij eksplicitan sadržaj Rezervne kopije i vraćanje Rezervna kopija Vraćanje Plejlista je uvezena Rezervna kopija je uspešno sagrađena Nije moguće napraviti rezervnu kopiju Neuspešno vraćanje rezervne kopije Discord integracija Metrolist koristi KizzyRPC biblioteku kako bi stavio tvoj Discord status. Ovo uključuje korištenje Discord Gateway spajanja, što bi se moglo smatrati prekršajem Discord-ovog TOS-a. Međutim nema poznatih slučajeva gde su korisnički nalozi bili suspendovani zbog ovog razloga. Korisite na svoj rizik.\n\nMetrolist će samo izvesti tvoj žeton, i sve drugo je sklađeno lokalno. Odbaci Opcije Omogući Bogatu Prisutnost O Metrolist-u Verzija aplikacije Dostupna je nova verzija Modeli prevođenja Obriši modele prevođenja Izvođači biblioteke će se pojaviti ovde Albumi biblioteke će se pojaviti ovde Pretraži biblioteku… Pesme koje vam se sviđaju Biblioteka Sviđa mi se Da li zaista želite da obrišete sve \"%s\" pesme sa plejliste iz skladišta Preuzetih Pesama? Pusti Dodaj u red Pusti sledeće Dodaj u biblioteku Ukloni iz istorije Dužina Vreme puštanja MIME tip Kodek Bitrata Napravi plejlistu Zvuk Izmeni plejlistu Sačuvaj Izaberi plejlistu Ime plejliste Nema internet veze Tekst pesme nije pronađen Plejlista sinhronizovana Merač vremena za spavanje Kraj pesme %d minut %d minuta %d minuta Prenos nije dostupan Istek vremena Sviđa mi se Nepoznata greška Razno Podrazumevana otvorena kartica Ponovo pokrenite da biste videli promenu Veliko Sadržaj Podrazumevana država sadržaja Obrišite sva preuzimanja Red Keš pesmi Skladište Keš slika Keš Maksimalna veličina keša Neograničeno Maksimalna veličina keša slika Obrišite keš slika Neuspešna prijava Pregled Izloguj se Koristite prijavu za pregledanje sadržaja Ovo može uticati na sadržaj koji vidite i, na primer, prikazivati albume koji su dostupni samo za Premium korisnike ako ste prijavljeni sa Premium nalogom Uloguj se ================================================ FILE: app/src/main/res/values-be/metrolist_strings.xml ================================================ Вокладка альбома У трэндзе Тыдні Месяцы Гады Чарты Назад Топ музычных відэа Упадабаныя Спампоўкі Мой топ Сінхранізаваць плей-ліст Сінхранізацыя адключана Заўвага: Гэта дазволіць сінхранізацыю з YouTube Music. Гэта НЕЛЬГА змяніць пазней. Генерацыя відарыса Пачакайце, калі ласка Скасаваць Падзяліцца тэкстам песні Абагуліць як тэкст Абагуліць як відарыс Цёмны рэжым Сістэмны рэжым Зялёны Жоўты Закрыць Фіялетавы Сіні Небесны сіні Светла зялёны Лайм Аранжавы Карычневы Серы Сіне серы Вельмі цёмны рэжым Светлы рэжым Лакальны Дістанцыйны Загружаны Загружаны Максімальны ліміт выбара Паказаць больш Паказаць менш Паказаць колькасць падпісчыкаў Спампаваць ўсі песні для слухання афлайн Выдаліць усі песні з гэтага плэйлісту Спампаванне ў прагрэсе Падзяліцца гэтым плэйлістом з другімі Выдаліць гэты плэйліст назаўсёды Сінхранізіраваць плэйліст з YouTube Music Скапіраваць спасылку Выбраць усё Спасылка скапіравана ў буфер абмену Зараз грае ================================================ FILE: app/src/main/res/values-be/strings.xml ================================================ Галоўнае Песні Выканаўцы Альбомы Плэй-лісты %d абрана %d абраны %d абрана %d абрана Гісторыя Статыстыка Настроі і жанры Уліковы запіс Хуткі выбар Праслухайце якія-небудзь кампазіцыі, каб стварыць ваш хуткі выбар. Новыя рэлізы альбомаў Сёння Учора На гэтым тыднія На тым тыдні Лепшыя кампазіцыі Лепшыя выканаўцы Пошук Пошук у YouTube Music… Пошук у бібліятэцы… Усе Песні Відэа Альбомы Выканаўцы Плэй-лісты Плэй-лісты aд cупольнасці Вартыя ўвагі плэй-лісты Вынікі не знойдзены З вашай бібліятэкі Упадабаныя кампазіцыi Спампаваныя кампазіцыі Плай-ліст пусты Паўтарыць Радыё Перамяшаць Падрабязнасці Змяніць Уключыць радыё Прайграць Прайграць наступным Дадаць у чаргу Дадаць у бібліятэку Выдаліць з бібліятэкі Спампаваць Спампоўка Выдаліць са спамповак Імпартаваць плей-ліст Дадаць у плэй-ліст Перайсці да выканаўца Перайсці да альбома Абнавіць Абагуліць Выдаліць Выдаліць з гісторыі Шукаць у сетцы Cінхранізацыя Нядаўна дададзена Назва Выканаўца Год Колькасць песен Працягласць Колькасць прайграванняў Уласны парадак Iдэнтыфікатар медыя Тып MIME Кодэкі Хуткасць Частата дыскрэтызацыі Гучнасць Узровень гучнасці Памер файла Невядомы Скапіявана Змяниць тэкст песні Пошук тэкста песні Змяніць песню Назва песні Выканаўца песні Песня павінна мець назву. Песня павінна мець выканаўца. Захаваць Выбраць плэй-ліст Змяніць плей-ліст Стварыць плей-ліст Назва плей-ліста Плей-ліст павінен мець назву. Змяніць выканайца Імя ваканаўца Выканаўца павінен мець імя. %d песня %d песні %d песен %d песен %d выканаўца %d выканаўцы %d выканаўцаў %d выканаўцаў %d альбом %d альбомы %d альбомаў %d альбомаў %d плей-ліст %d плей-ліста %d плей-лістоў %d плей-лістоў %d тыдзень %d тыдні %d тыдняў %d тыдняў %d месяц %d месяцы %d месяцаў %d месяцаў %d год %d гады %d raдоў %d raдоў Плей-ліст імпартаваны \"%s\" выдалена з плей-ліста Плей-ліст сінхранізаваны Адрабіць Тэкст песні не знойдзены Таймер рэжыму сну Канец песні %d хвіліна %d хвіліны %d хвілін %d хвілін Няма даступных плыняў Няма падлучэння да сеткі Час чакання Невядомая памылка Упадабаць Выдаліць з упадабаных Уключыць перамешванне Выключыць перамешванне Рэжым паўтору выключаны Паўтарыць бягучую песню Паўтарыць чаргу Усе песні Шуканыя кампазіцыі Музычны прайгравальнік Налады Выгляд Уключыць дынамічную каляровую тэму Цёмная каляровая тэма Укл. Выкл. Прытрымлівацца сістэмнай каляровай тэмы Рэжым чыстага чорнага колеру Прадвызначаная укладка Дапасаваць укладак Пазіцыя тэкста песні Па левым краі Па цэнтры Па правым краі Змесціва Лагін Мова змесціва Краіна змесціва Прытрымлівацца сістэмнай Уключыць проксі Тып проксі URL проксі Перазапусціць каб ужыць змяненні Прайгравальнік ды аўдыя Якасць аўдыя Аўта Высокая Нізкая Сталая чарга Прапусціць цішыню Нармалізацыя аўдыя Эквалайзер Сховішча Кеш Кеш выяў Кеш песен Максімальны памер кэшу Безліміт Ачысціць усе спампоўкі Максімальны памер кэшу выяў Ачысціць кэш выяў Максімальны памер кэшу песен Ачысціць кэш песен %s выкарыстоўваецца Прыватнасць Прыпыніць гісторыю праслухоўвання Ачысціць гісторыю праслухоўвання Сапраўды ачысціць гісторыю праслухоўвання? Прыпыніць гісторыю пошуку Ачысціць гісторыю пошуку Сапраўды ачысціць гісторыю пошуку? Шукаць тэксты песен у KuGou Рэзервовае капіраванне ды аднеўленне Рэзервовае капіраванне Аднаўленне з рэзервовай копіі Імпартаваны плей-ліст Рэзервовная копія паспяхова створана Немагчыма стапрыць рэзервовую копію Немагчыма ужывіць рэзервовую копію Аб праграме Версія праграмы Даступная новая версія Мадэлі перакладу Ачысціць мадэлі перакладу ================================================ FILE: app/src/main/res/values-bg/metrolist_strings.xml ================================================ Класации Назад Корица на албум Топ музикални клипове Популярни в момента Седмици Месеци Години Харесани Изтеглени Кеширани Синхронизирай плейлист Бележка: Това позволява сихронизирането с YouTube Music. НЕ МОЖЕ да промените това по-късно. Генериране на изображение Моля изчакайте Отказ Сподели текст Сподели като текст Сподели като изображение Сподели избраното Персонализирай цветовете Цвят на текста Вторичен цвят на текста Цвят на фона Изтрий от кеша Копирай линка Избери всички Харесай всички Отхаресай всичко Дата на обновяване Линкът е копиран в клипборда Текст Вече в плейлиста: %d време %d времена Подобно съдържание Настройки на фона на плеъра Следвай темата Градиент Замъглено Цветове на бутоните на плеъра По подразбиране Смени текста с клик Автоматично превъртане на текст Романизирай японски текстове Романизирай корейски текстове Тънка долна навигационна лента Автоматични плейлисти Покажи плейлист \"Харесани\" Покажи плейлист \"Изтеглени\" Покажи плейлист \"Топ\" Покажи плейлист \"Кеширани\" Вход с токен Натисни за показване на токен Натисни отново за копиране или редакция Това е метод за влизане за ОПИТНИ потребители. Като алтернатива на уеб портала можете директно да въведете или актуализирате своя идентификационен код тук. Така например може да се ускори влизането в системата от няколко устройства. Моля, имайте предвид, че всички невалидни формати на токени, които приложението не успява да анализира, няма да бъдат приети Автоматично синхронизиране с акаунт Повече съдържание Общи Прокси Задайте бързи избори На база последно слушана песен Език на приложението %d%% Последната седмица Последния месец Последната година Информация Описание Гледания Харесвания Нехаресвания 1 секунда %d секунди Локални Дистанционно Продължаващо Мойте топ Синхронизирането е изключено Максимален лимит за избиране Разреши плъзгането за смяна на песента Плъзнете песента наляво, за да я добавите към опашката, или надясно, за да я пуснете следваща Тесен Нов дизайн на плеъра Показвай подобно съдържание Автоматично добавяне на подобни песни при достигане на края на опашката Импортиране на M3U плейлисти Импортиране на CSV плейлисти Бележка: Добавянето на локални песни към синхронизирани/отдалечени плейлисти не се поддържа. Всички други комбинации са валидни Автоматично изтегляне при харесване Автоматично сваляй песни при харесване Чувствителност на жестовете в мини плеъра %1$d%% Сигурни ли сте, че искате да изчистите всички кеширани песни? Сигурни ли сте, че искате да изчистите всички кеширани изображения? Наистина ли искате да изчистите всички изтеглени файлове? Деактивирай Не сте влезли в YouTube Отвори поддържани връзки Неуспешно отваряне на настройките на приложението Информация за новата версия Общо Последните 24 часа Дължина на моя топ лист Продължителност на историята Абонирай се Абониран Нов дизайн на мини плеър Сега слушате Затвори Заменете корицата на албума с логото на приложението в плеъра +%1$d секунди напред -%1$d секунди назад Качено Качени Стартиране на радио Скриване на миниатюрата на плейъра Ако е активирано, добавя допълнителни 5 секунди всеки път, когато прескочите превъртането Показване на плейлиста „Качени“ Редактиране на обложката на плейлиста Забележка: За да променяте обложката на плейлиста, акаунтът ви трябва да е свързан с телефонен номер и потвърден в YouTube Music. След като изберете изображение, моля, изчакайте малко, докато новата обложка се появи във Вашия плейлист. Изберете от библиотеката Премахване на персонализирано изображение Конфигуриране на прокси Потребителско име на прокси Парола на прокси Активиране на удостоверяване Използвайте подробности вместо статус Показване заглавията на песните вместо имената на изпълнителите Деактивиране на автоматичното изтегляне при повтаряне на всички песни Не изтегляйте автоматично още песни и подобно съдържание, когато е активирано „Повтаряне на всички“ Кирилица Романизация Романизация на текста на песните Романизиране на руски текстове Романизиране на украински текстове Романизиране на беларуски текстове Романизиране на киргизки текстове Романизиране на сръбски текстове Романизиране на български текстове ЕКСПЕРИМЕНТАЛНО: Откриване на език ред по ред Текстът на кирилица ще се разпознава ред по ред, вместо за цялата песен. Сигурен ли сте? Това е експериментална функция и може да не работи винаги.\n\nПо подразбиране езикът се определя от цялата песен, но когато тази опция е включена, той ще се определя ред по ред. Това ще позволи многоезичните песни да работят, НО езикът може да не е правилен винаги (например, ако има украински текст, който не съдържа специфични за украинския език букви, той може да бъде романизиран като руски).\n\nАко нямате проблеми, препоръчително е да оставите тази опция изключена. Романизиране на текущата песен Интерфейс Поверителност и сигурност Плейър и съдържание Съхранение и \'БекЪп\' Системни & относно приложението Актуализатор Автоматична проверка за актуализации Активиране на известия за актуализации Налична е актуализация Актуализации на приложения Известия за нови версии Активиране на хардуерно аудио ускорение Използвайте хардуерно възпроизвеждане на аудио. Деактивирането на тази функция може да увеличи консумацията на енергия, но може да бъде полезно, ако имате проблеми с възпроизвеждането на аудио или последващата обработка Романизиране на македонските текстове Интеграции Потребителско име Парола Интеграция с Last.fm Активиране на скробълинг Изпращане на данни за текущата песен Конфигурация на скробълинга Процент на забавяне при Scrobble Закъснение на Scrobble в минути Кумулативно превъртане Плъзнете върху песента, за да я премахнете от плейлиста Промяна на \'дефолтния\' чип на библиотеката Песни от Scrobble, по-дълги от Основен цвят Третичен цвят Повторно синхронизиране Романизиране на китайски текстове Google Cast Активиране на предаване на звук към Chromecast и други устройства поддържащи Cast Изпращане на харесвания/нехаресвания Добавяне/премахване на любимо в Last.fm, когато харесате/премахнете песен в Metrolist Влизане… Скриване на видео Преглед на информацията за песента Промяна на заглавието или изпълнителя Изтеглете всички песни за възпроизвеждане офлайн Премахване на всички изтеглени песни от този плейлист Изтеглянето е в ход Споделете плейлистата с други Изтрий за постоянно този плейлист Включване на Better Lyrics Запазване в библиотеката Направете достъпно офлайн Споделете линк на този елемент ================================================ FILE: app/src/main/res/values-bg/strings.xml ================================================ Изпълнители Албуми Плейлисти %d избран %d избрани Подобно на Нови албуми Днес Вчера Тази седмица Миналата седмица Песни Начало История Настроения и жанрове Статистика Бърз избор Продължи да слушаш Профил Слушай песни, за да генерираш своя бърз избор Забравени любими Вашите плейлисти в YouTube Най-пускани песни Други версии Радио Стартирай радио Най-пускани албуми Библиотека Албуми Търсене Изтеглени песни Плейлистът е празен От вашата библиотека Изпълни Наистина ли искате да премахнете всички \"%s\" плейлист песни от хранилището за изтеглени песни? Песните от библиотеката ще се показват тук Опитай отново Търсене в библиотека… Изпълнители Най-пускани изпълнители Търсене в YouTube Music… Харесвани Изтеглени Всички Песни Няма намерени резултати Плейлисти Видеоклипове Плейлисти на общността Маркирано Представени плейлисти Изпълнителите от библиотеката ще се показват тук Албумите от библиотеката ще се показват тук Харесвани песни Вашите плейлисти ще се показват тук Наистина ли искате да изтриете плейлиста \"%s\"? Добави към библиотеката Разбъркай Нулиране Изпълни следващ Подробности Редактирай Добави към опашката Темпо и височина Дата на добавяне Премахни от плейлиста Премахни от историята Копирано в клипборда Вижте албум Синхронизиране Добави всички към библиотеката ID на медия Търси текстове Неизвестен Тип MIME Кодеци Добави към плейлист Потребителско Увеличаване Премахни от опашката Брой песни Сила на звука Премахни всички от библиотеката Честота на дискретизация Размер на файла Сподели Премахни изтегляне Вижте изпълнител Премахни от библиотеката Изтегляне Изтегля се Внасяне на плейлист Повторно извличане Изтрий Търси онлайн Разширени Име Изпълнител Година Дължина Време на изпълнение Скорост на предаване Редактирай текстове %d песен %d песни %d месец %d месеци %d година %d години 1 минута %d минути Името на плейлиста не може да бъде празно. Име на плейлист Заглавие на песен Време за изчакване Избери плейлист Създаване на плейлист Всички харесвания Песента вече е във вашия плейлист Няма мрежова връзка Плейлистът е синхронизиран Заглавието на песента не може да бъде празно. Разбъркване включено Редактирай песен Изпълнители на песен Изпълнителят на песента не може да бъде празен. Запази Редактирай плейлист Редактирай изпълнител Име на изпълнител Името на изпълнителя не може да бъде празно. Дубликати Пропусни дубликати Добави все пак %d песни вече са във вашия плейлист %d изпълнител %d изпълнители %d албум %d албуми %d плейлист %d плейлисти %d седмица %d седмици Плейлистът е внесен Премахнат „%s“ от плейлиста Отмяна Текстът не е намерен Таймер за заспиване Край на песента Няма наличен поток Неизвестна грешка Премахни харесвания Харесвания Премахни всички харесвания Разбъркване изключено Режим на повторение е изключен Повтори текущата песен Повтори опашката Metrolist използва библиотеката KizzyRPC, за да зададе състоянието на профила ви в Discord. Това включва използването на връзката Discord Gateway, което може да се счита за нарушение на TOS на Discord. Въпреки това няма известни случаи на спиране на потребителски профили поради тази причина. Използвайте на свой собствен риск. \n \nMetrolist ще извлече само вашия токен, а всичко останало се съхранява локално. Архивиране Модели за превод Изчисти кеша на изображение Неуспешно създаване на резервно копие Активирай динамична тема Изчисти кеша на песен Неуспешно влизане Внесен плейлист Опашка Автоматично Вход Чисто черно Пропусни тишината Ниско Надясно Позиция на текста на песен Търсени песни Плъзгащ стил на плейъра Държава на съдържание по подразбиране Кеш на изображение Активирай прокси Тъмна тема Съхранение Ясни модели за превод Прокси URL История на търсене Автоматично премини към следваща песен, когато възникне грешка Настройки Възстановяване Плейър Подравняване текста на плейъра Сигурни ли сте, че искате да изчистите цялата история на търсене? Кеш на песен Не сте влезли в Всички песни Версия на приложението Относно Тема Архивиране и възстановяване Център Съдържание Осигурете си непрекъснато изживяване при възпроизвеждане Пауза на историята на слушане Активирай доставчик на текстове KuGou Криволичещо Система по подразбиране Персонализирай разделите за навигация Музикален плейър Външен вид Включено Изключено Следвай системата Наляво Странично По подразбиране Разни Отвори раздел по подразбиране Размер на мрежовата клетка Малък Голям Език на съдържание по подразбиране Тип прокси Рестартирай за да влезе в сила Плейър и аудио Качество на звука Високо Постоянна опашка Възстанови последната си опашка при стартиране на приложението Автоматично зареди още песни Автоматично добави още песни при достигане края на опашката, ако е възможно Нормализация на звука Спри музиката при изчистване на задачата Еквалайзер Кеш Максимален размер на кеша Неограничен Изчисти всички изтеглени Максимален размер на кеша за изображение Максимален размер на кеша на песен %s използвани Поверителност История на слушане Изчисти историята на слушане Сигурни ли сте, че искате да изчистите цялата история на слушане? Пауза на историята на търсене Изчисти историята на търсене Деактивирай снимка на екрана Когато тази опция е включена, снимка на екрана и изгледът на приложението в Скорошни са деактивирани. Активирай доставчик на текстове LrcLib Скрий нецензурно съдържание Архивирането е създадено успешно Неуспешно възстановяване на резервно копие Discord интеграция Отхвърли Опции Преглед Изход Активирай Rich Presence Налична е нова версия Използвай вход за разглеждане на съдържание Това може да повлияе какво съдържание виждате и например показва само премиум албуми, ако сте влезли с Премиум профил Вход ================================================ FILE: app/src/main/res/values-bn/metrolist_strings.xml ================================================ ফোন স্টোরেজ দুরবর্তী শীর্ষ তালিকা পূর্ববর্তী এ্যালবাম কভার শীর্ষ মিউজিক ভিডিও ট্রেন্ডিং সপ্তাহ মাস বছর বিরতিহীন পছন্দ ডাউনলোডেড শীর্ষ গান জমাকৃত আপলোডেড আপলোডেড প্লেলিস্ট সমন্বয় সমন্বয় বন্ধ নোট: এটা ইউটিউব মিউজিকের সাথে সমন্বয় করতে দেয়। পরবর্তীতে পরিবর্তন করা যাবেনা। ছবি তৈরী হচ্ছে অপেক্ষা করুন বাতিল লিরিক্স শেয়ার লেখা হিসেবে শেয়ার ছবি হিসেবে শেয়ার সর্বোচ্চ নির্বাচন সংখ্যা নির্বাচিতগুলো শেয়ার রঙ পরিবর্তন লিখার রঙ লিখার রঙ (গৌণ) ব্যাকগ্রাউন্ড এর রঙ জমা থেকে মুছে ফেলা হয়েছে উদ্দেশ্য %d সেকেন্ড %d সেকেন্ড কম দেখুন শিল্পীর সম্পর্কে দেখুন সাবস্ক্রাইবার সংখ্যা দেখুন মাসিক শ্রোতা সংখ্যা দেখুন অফলাইনে শোনার জন্য সব গান ডাউনলোড করুন এই প্লেলিস্ট থেকে সব ডাউনলোড গান সরিয়ে দিন ভাউনলোড চলছে এই প্লেলিস্টটি অন্যদের সাথে শেয়ার করুন এই প্লেলিস্টটি সম্পূর্ণভাবে মুছে ফেলুন ================================================ FILE: app/src/main/res/values-bn/strings.xml ================================================ হোম গান আর্টিস্ট অ্যালবাম প্লেলিস্ট %d সিলেক্টেড %d সিলেক্টেড হিস্ট্রি স্টাটস মুড ও জনরা অ্যাকাউন্ট কুইক পিকস্ কুইক পিক তৈরি করতে আগে কিছু গান শুনুন নতুন অ্যালবাম ও সিঙ্গেল আজ গতকাল এই সপ্তাহ গত সপ্তাহ সর্বাধিক শোনা গানগুলি সর্বাধিক শোনা আর্টিস্ট সর্বাধিক শোনা অ্যালবাম সার্চ YouTube Music এ সার্চ করুন লাইব্রেরিতে সার্চ করুন লাইব্রেরি লাইকড ডাউনলোডেড সব গান ভিডিও অ্যালবাম আর্টিস্ট প্লেলিস্ট কমিউনিটি প্লেলিস্ট ফিচার্ড প্লেলিস্ট বুকমার্কর্ড কিছু পাওয়া যায়নি আপনার লাইব্রেরি থেকে আপনার লাইক করা গান ডাউনলোড করা গান প্লেলিস্ট খালি আবার চেষ্টা করুন রেডিও শাফেল রিসেট বিস্তারিত এডিট রেডিও চালু করুন প্লে পরবর্তী গান কিউতে অ্যাড করুন লাইব্রেরি তে অ্যাড করুন লাইব্রেরি থেকে সরান ডাউনলোড ডাউনলোড হচ্ছে ডাউনলোড থেকে সরান প্লেলিস্ট ইমপোর্ট করুন প্লেলিস্টে অ্যাড করুন আর্টিস্ট দেখুন অ্যালবাম দেখুন রিফ্রেশ শেয়ার ডিলিট হিস্ট্রি থেকে সরান অনলাইন এ খুঁজুন সিঙ্ক্রোনাইজ অ্যাডভান্স দিন নাম আর্টিস্ট বছর মোস্ট প্লেইড গানের লেন্থ প্লে টাইম কাস্টম মিডিয়া আইডি মাইম টাইপ কডেক বিটরেট স্যাম্পল রেট লাউডনেস ভলিউম ফাইল সাইজ আননোন ক্লিপবোর্ডে কপি করা হয়েছে গানের লিরিক এডিট গানের লিরিক সার্চ গান এডিট করুন টাইটেল আর্টিস্ট গানের টাইটেল খালি হতে পারে না। গানের আর্টিস্ট খালি থাকতে পারে না। সেভ একটি প্লেলিস্ট সিলেক্টে করুন প্লেলিস্ট এডিট করুন প্লেলিস্ট তৈরি করুন প্লেলিস্টের নাম প্লেলিস্টের নাম ফাকা রাখা যাবে না। আর্টিস্ট এডিট করুন আর্টিস্টের নাম আর্টিস্টের নাম খালি রাখা যাবে না। %d গান %d টা গান %d আর্টিস্ট %d টা আর্টিস্ট %d অ্যালবাম %d টা অ্যালবাম %d প্লেলিস্ট %d টা প্লেলিস্ট %d সপ্তাহ %d সপ্তাহ %d মাস %d মাস %d বছর %d বছর ইমপোর্ট করা প্লেলিস্ট প্লেলিস্ট থেকে \"%s\" সরানো হয়েছে সিঙ্ক্রোনাইজ করা প্লেলিস্ট বাতিল করুন গানের লিরিক পাওয়া যায়নি স্লীপ টাইমার গান শেষে %d মিনিট %d মিনিট স্ট্রিম পাওয়া যায়নি নেটওয়ার্ক কানেকশন নেই ইরর টাইমআউট আননোন ইরর লাইক অলাইক শাফেল অন শাফেল অফ রিপিট অফ একটা গান রিপিট রিপিট কিউ সব গান কিউ সার্চ করা গান কিউ প্লেয়ার সেটিংস স্টাইল ডাইনামিক থিম চালু করুন ডার্ক থিম অন অফ সিস্টেম থিম ব্ল্যাক ডিফল্ট ট্যাব নেভিগেশন ট্যাব কাস্টমাইজ করুন লিরিকের পজিশন বামে মাঝে ডানে কনটেন্ট লগ ইন ডিফল্ট কন্টেন্টের ভাষা ডিফল্ট কন্টেন্টের দেশ সিস্টেমের ডিফল্ট প্রক্সি চালু করুন প্রক্সির ধরণ প্রক্সি URL পরিবর্তনগুলি আপ্লাই করতে অ্যাপটি আবার চালু করুন প্লেয়ার ও অডিও অডিও কোয়ালিটি অটো হাই লো অনবরত কিউ নীরব সময়টুকু স্কিপ করুন অডিও নরমালাইজেশন অডিও টিউনার স্টোরেজ ক্যাশ ইমেজ ক্যাশ অডিও ক্যাশ ক্যাশ সাইজ আনলিমিটেড সব ডাউনলোড মুছুন ইমেজ ক্যাশ সাইজ ইমেজ ক্যাশে ক্লিয়ার করুন অডিও ক্যাশ সাইজ অডিও ক্যাশে ক্লিয়ার করুন %s ব্যবহৃত গোপনীয়তা প্লেব্যাক হিস্ট্রি বন্ধ করুন প্লেব্যাক হিস্ট্রি ক্লিয়ার করুন প্লেব্যাক হিস্ট্রি মুছে ফেলবেন? সার্চ হিস্ট্রি বন্ধ করুন সার্চ হিস্ট্রি ক্লিয়ার করুন সার্চ হিস্ট্রি মুছে ফেলবেন? KuGou থেকে লিরিক নিন ব্যাকআপ এবং রিস্টোর ব্যাকআপ রিস্টোর ইমপোর্টেড করা প্লেলিস্ট ব্যাকআপ তৈরি করা হয়েছে ব্যাকআপ করতে বার্থ ব্যাকআপ থেকে রিস্টোর করতে অক্ষম অ্যাপ সম্পর্কে অ্যাপ ভার্সন নতুন ভার্সন এসেছে ট্রান্সলেশন মডেল ট্রান্সলেশন মডেল ক্লিয়ার করুন ভুলে যাওয়া প্রিয়গুলি শুনতে থাকুন আপনার ইউটিউব প্লেলিস্টগুলি সদৃশ লাইব্রেরির গানগুলি এখানে প্রদর্শিত হবে লাইব্রেরির শিল্পীরা এখানে উপস্থিত হবেন লাইব্রেরি অ্যালবামগুলি এখানে প্রদর্শিত হবে আপনার প্লেলিস্টগুলি এখানে প্রদর্শিত হবে অন্যান্য সংস্করণসমূহ আপনি কি সত্যিই \"%s\" প্লেলিস্টের সব গান ডাউনলোড করা গানের স্টোরেজ থেকে মুছে ফেলতে চান? আপনি কি সত্যিই প্লেলিস্ট \"%s\" মুছে ফেলতে চান? সবগুলো লাইব্রেরিতে যোগ করুন লাইব্রেরি থেকে সব মুছে ফেলুন প্লেলিস্ট থেকে সরান কিউ থেকে সরান টেম্পো এবং পিচ নকলগুলি ডুপ্লিকেটগুলি এড়িয়ে যান যাই হোক যোগ করুন গানটি ইতিমধ্যেই আপনার প্লেলিস্টে রয়েছে %d গান ইতিমধ্যেই আপনার প্লেলিস্টে রয়েছে সবাইয়ের মতো সব লাইক মুছে ফেলুন থিম প্লয়ার প্লেয়ার টেক্সট সারিবদ্ধকরণ পাশযুক্ত প্লেয়ার স্লাইডার স্টাইল ডিফল্ট স্কুইগলি বিবিধ গ্রিড সেল সাইজ ছোট বড় লগ আউট করুন লগ ইন করুন লগ ইন করা হয়নি লগইন ব্যর্থ হয়েছে কিউ অ্যাপ শুরু হলে আপনার শেষ কিউ পুনরুদ্ধার করুন অটো আরও গান লোড করুন যদি সম্ভব হয়, কিউর শেষ হলে স্বয়ংক্রিয়ভাবে আরও গান যোগ করুন ত্রুটি ঘটলে স্বয়ংক্রিয়ভাবে পরবর্তী গানে চলে যাওয়া আপনার অবিচ্ছিন্ন প্লেব্যাক অভিজ্ঞতা নিশ্চিত করুন টাস্ক ক্লিয়ার হলে মিউজিক বন্ধ করুন ইতিহাস শুনুন অনুসন্ধান ইতিহাস ব্রাউজিং কন্টেন্টের জন্য লগইন ব্যবহার করুন এটি আপনার দেখা কন্টেন্টকে প্রভাবিত করতে পারে এবং উদাহরণস্বরূপ, যদি আপনি একটি প্রিমিয়াম অ্যাকাউন্ট দিয়ে লগইন করেন তবে শুধুমাত্র প্রিমিয়াম অ্যালবামগুলি দেখায় স্ক্রিনশট নিষ্ক্রিয় করুন যখন এই বিকল্পটি চালু থাকে, তখন স্ক্রিনশট এবং রিসেন্টস-এ অ্যাপের ভিউ নিষ্ক্রিয় থাকে। LrcLib গানের লিরিক্স প্রদানকারী সক্রিয় করুন স্পষ্ট বিষয়বস্তু লুকান ডিসকর্ড ইন্টিগ্রেশন Metrolist আপনার Discord অ্যাকাউন্টের স্ট্যাটাস সেট করার জন্য KizzyRPC লাইব্রেরি ব্যবহার করে। এর মধ্যে Discord Gateway সংযোগ ব্যবহার করা হয়, যা Discord-এর পরিষেবার শর্তাবলীর (TOS) লঙ্ঘন হিসেবে বিবেচিত হতে পারে। তবে, এই কারণে ব্যবহারকারীর অ্যাকাউন্ট সাসপেন্ড হওয়ার কোনো পরিচিত ঘটনা নেই। ব্যবহার আপনার নিজের ঝুঁকিতে করুন।\n\nMetrolist শুধুমাত্র আপনার টোকেন সংগ্রহ করবে, এবং বাকী সবকিছু স্থানীয়ভাবে সংরক্ষিত থাকবে। বাতিল করুন বিকল্পসমূহ প্রিভিউ রিচ প্রেজেন্স সক্ষম করুন ================================================ FILE: app/src/main/res/values-bn-rIN/strings.xml ================================================ হোম গান শিল্পী অ্যালবাম প্লেলিস্ট %d নির্বাচিত %d নির্বাচিত ইতিহাস পরিসংখ্যান মেজাজ এবং শৈলী অ্যাকাউন্ট দ্রুত পছন্দ আপনার নিজের শর্টকাট তৈরি করতে কিছু টিউন শুনুন নতুন অ্যালবাম ও সিঙ্গেল আজ গতকাল এই সপ্তাহ গত সপ্তাহ সর্বাধিক শোনা গানগুলি সর্বাধিক শোনা শিল্পী সর্বাধিক শোনা অ্যালবাম খুঁজুন খুঁজুন YouTube Music এ খুঁজুন লাইব্রেরি তে সব সংগীত ভিডিও অ্যালবাম শিল্পী প্লেলিস্ট প্লেলিস্ট ক্রম বিশিষ্ট প্লেলিস্ট কিছু পাওয়া যায়নি আপনার লাইব্রেরি থেকে আপনার পছন্দের গান ডাউনলোড করা গান প্লেলিস্ট খালি পুনরায় চেষ্টা করুন বেতার এলোমেলো বিস্তারিত সম্পাদনা বেতার খুলুন গান বাজান পরবর্তী গান বাজান পরবর্তী গান যোগ করুন লাইব্রেরি তে যোগ করুন লাইব্রেরি থেকে সরান ডাউনলোড ডাউনলোড হচ্ছে ডাউনলোড মুছে ফেলুন প্লেলিস্ট আমদানি করুন প্লেলিস্টে যোগ করুন শিল্পী দেখুন অ্যালবাম দেখুন আবার আনুন শেয়ার মুছুন ইতিহাস থেকে অপসারণ অনলাইন এ খুঁজুন সুসংগত সময় সম্পাদনা করা হয়েছে নাম শিল্পী বছর প্লে সংখ্যান দৈর্ঘ্য বাজানো হয়েছে অনুকুলিত শব্দের মাত্রা ধ্বনির মাত্রা ফাইলের আকার অজানা ক্লিপবোর্ডে কপি করা হয়েছে গানের কথা সম্পাদনা গানের কথা অনুসন্ধান গান সম্পাদনা করুন শিরোনাম শিল্পী গানের শিরোনাম খালি হতে পারে না। গানের শিল্পী খালি থাকতে পারে না। সংরক্ষণ একটি প্লেলিস্ট চয়ন করুন প্লেলিস্ট সম্পাদনা করুন প্লেলিস্ট তৈরি করুন প্লেলিস্টের নাম প্লেলিস্টের নাম খালি রাখা যাবে না। শিল্পী সম্পাদনা করুন শিল্পীর নাম শিল্পীর নাম খালি রাখা যাবে না। %d গান %d গান %d শিল্পী %d শিল্পী %d অ্যালবাম %d অ্যালবাম %d প্লেলিস্ট %d প্লেলিস্ট %d সপ্তাহ %d সপ্তাহ %d মাস %d মাস %d বছর %d বছর আমদানি করা প্লেলিস্ট প্লেলিস্ট থেকে \"%s\" সরানো হয়েছে সিঙ্ক্রোনাইজ করা প্লেলিস্ট বাতিল করুন গানের কথা পাওয়া যায়নি ঘুমের টাইমার গানের শেষ %d মিনিট %d মিনিট স্ট্রিম উপলব্ধ নয় নেটওয়ার্ক সংযোগ নেই ত্রুটি সময়সীমা শেষ অজানা ত্রুটি পছন্দ অপছন্দ সব গান অনুসন্ধান করা গান প্লেয়ার সেটিংস দৃষ্টিগোচরতা গতিশীল থিম সক্ষম করুন অন্ধকার থিম সক্রিয় নিষ্ক্রিয় সিস্টেম অনুসরণ করুন কালো ডিফল্ট প্রধান ট্যাব নেভিগেশন ট্যাব কাস্টমাইজ করুন গানের কথার অবস্থান বাম কেন্দ্র ডান কনটেন্ট সাইন ইন ডিফল্ট কন্টেন্টের ভাষা ডিফল্ট কনটেন্ট দেশ সিস্টেমের ডিফল্ট প্রক্সি সক্রিয় করুন প্রক্সি ধরণ প্রক্সি URL পরিবর্তনগুলি প্রয়োগ করতে অ্যাপটি পুনরায় চালু করুন প্লেয়ার এবং অডিও অডিওর মান স্বয়ংক্রিয় উচ্চ নিম্ন অবিরাম সারি নীরবতা এড়িয়ে যান অডিও স্বাভাবিকীকরণ অডিও টিউনার স্টোরেজ ক্যাশে ইমেজ ক্যাশে অডিও ক্যাশে সর্বাধিক ক্যাশে আকার সীমাহীন সমস্ত ডাউনলোড মুছুন ইমেজ ক্যাশে সর্বাধিক আকার ইমেজ ক্যাশে সাফ করুন অডিও ক্যাশে সর্বোচ্চ আকার অডিও ক্যাশে সাফ করুন %s ব্যবহৃত গোপনীয়তা আপনার শোনার ইতিহাস থামান আপনার শোনার ইতিহাস সাফ করুন আপনি কি আপনার শোনার ইতিহাস মুছে ফেলার বিষয়ে নিশ্চিত? আপনার অনুসন্ধান ইতিহাস স্থগিত আপনার অনুসন্ধান ইতিহাস সাফ করুন আপনি কি আপনার অনুসন্ধান ইতিহাস মুছে ফেলার বিষয়ে নিশ্চিত? KuGou দ্বারা প্রদত্ত গানের কথা সক্রিয় করুন ব্যাকআপ এবং পুনঃস্থাপন ব্যাকআপ পুনঃস্থাপন আমদানি করা প্লেলিস্ট ব্যাকআপ সফলভাবে তৈরি করা হয়েছে ব্যাকআপ করতে অক্ষম ব্যাকআপ থেকে পুনরুদ্ধার করতে অক্ষম সম্পর্কিত অ্যাপ সংস্করণ ================================================ FILE: app/src/main/res/values-bs/metrolist_strings.xml ================================================ Automatsko pomjeranje stihova Prikaži \"Keširanu\" plejlistu Udaljen Nazad U trendu Sedmice Godina Kontinuirano Skinuto Sinhronizovana plejlista Molimo vas sačekajte Izaberi sve #Sličan #sadržaj %d put %d puta %d puta Zaprati temu Gradijent Zamućenje Boje dugmadi igrača #Igrač #pozadina #stil Podrazumevani stil Omogući promjenu pjesme prevlačenjem Prevucite pjesmu ulijevo da biste je dodali u red čekanja ili udesno da biste je pustili sljedeću Promijenite stihove klikom Sakrij oznake donje navigacijske trake Automatske plejliste Usko Prikaži plejliste \"Sviđa mi se\" Prikaži plejlistu “Preuzeto” Prikaži \"Najbolju\" plejlistu Lokalno Mjeseci Napredni login(token) Pritisni da pokažeš token Pritisni opet da kopiraš ili urediš Generalno Proxy Promijenite zadani bibliotečki čip Postavite brze odabire Bazirano na zadnju pjesmu koju ste slušali Jezik aplikacije Omogućite sličan sadržaj Automatski dodaj još sličnih pjesama kada se dođe do kraja reda čekanja Bilješke o izdanju Otvorite podržane veze Nije moguće otvoriti postavke aplikacije Dislajkova Uvezite \"m3u\" plejliste Uvoz playlista iz \"csv\" formata Automatski preuzmite pjesme kada vam se sviđaju Jeste li sigurni da želite obrisati sve keširane pjesme? Jeste li sigurni da želite obrisati sva preuzimanja? Sve vrijeme Zadnjih 24 sata Prošle sedmice Prošli mjesec Dužina moje top liste Informacije Opis Pregleda Lajkova Grafikoni Naslovnica albuma Najbolji muzički spotovi Lajkovano Moj top Keširano Sinhronizacoja onemogućena Napomena: Ovo omogućava sinhronizaciju s YouTube Music. Ovo se NE može kasnije promijeniti. Generisanje slike Otkaži Podjeli stihove Podjeli kao tekst Podjeli kao sliku Maksimalna granica izbora Dijeljenje odabrano Prilagodite boje Boja teksta Sekundarna boja teksta Boja pozadine Ukloni iz keša Kopiraj vezu Lajkuj sve Dislajkuj sve Datum ažuriran Link je kopiran u međuspremnik Stihovi Već je u plejlisti: Automatsko preuzimanje na lajk Prošle godine Napomena: Dodavanje lokalnih pjesama na sinhronizovane/udaljene plejliste nije podržano. Bilo koja druga kombinacija je važeća %d%% Niste prijavljeni na YouTube Trajanje istorije %dsekund %dsekunde %dsekundi Ovo je NAPREDNA metoda prijave. Kao alternativa web portalu, ovdje možete direktno unijeti ili ažurirati svoj token za prijavu. Na primjer, ovo može ubrzati prijavu na više uređaja. Imajte na umu da neće biti prihvaćeni nevažeći formati tokena koje aplikacija ne uspije analizirati ================================================ FILE: app/src/main/res/values-bs/strings.xml ================================================ Albumi Pjesme Slično ko %d izabran %d izabrana %d izabrano Historija Zaboravljeni favoriti Ove sedmice Pretraži Istaknuti spisci Umjetnici biblioteke će se ovdje pojaviti Preuzete pjesme Da li zaista želite da izbrišete spisku \"%s\"? Izmješaj Ponovo pokreni Pokaži umjetnika Datum dodavanja Id medija MIME tip Kodek Bitrata Naziv pjesme Napravi spisku Dodaj svejedno %d pjesme su već u vašoj spisci %d pjesma %d pjesme %d pjesama %d umjetnik %d umjetnika %d umjetnika %d album %d albuma %d albuma %d spisak %d spiske %d spiskih %d sedmica %d sedmice %d sedmica %d mjesec %d mjeseca %d mjeseci %d godina %d godine %d godina Tekst nije pronađen Prijenos nije dostupan Miješanje uključeno Veličina ćelije mreže Preskoči tišinu Normalizacija zvuka Skladište Keš Privatnost Historija pretrage Pauziraj historiju pretrage Onemogući slikanje zaslona Omogući KuGou dobavljača teksta Rezervna kopija je uspješno sagrađena Nije moguće napraviti rezervnu kopiju Neuspješan povratak rezervne kopije Integracija sa Discord-om Odbaci Početna Vrati Umjetnici Spisci za reprodukciju Pretraži YouTube Music… Iz vaše biblioteke Statistike Raspoloženje i Žanrovi Brzi izbori Račun Odslušaj pjesme kako bi generisali vaše brze odabire Nastavite da slušate Vaši YouTube spisci za reprodukciju Najslušaniji albumi Albumi Novi albumi Pjesme Danas Najslušaniji umjetnici Jučer Prošle sedmice Najslušanije pjesme Pretraži biblioteku… Biblioteka Obilježeno Pjesme biblioteke će se ovdje pokazati Sviđa mi se Preuzeto Sve Tempo i Visina tona Video-nadzori Vaši spisci će se ovdje pojaviti Umjetnici Spisci za reprodukciju Zajednički spisci Nema rezultata Albumi biblioteke će se ovdje pojaviti Pjesme koje vam se sviđaju Druge verzije Popis je prazan Da li zaista želite da obrišete sve \"%s\" pjesme iz spiska iz skladišta Preuzetih Pjesama? Probaj ponovo Radio Pokrenite radio Ukloni iz svih biblioteka Detalji Dodaj u red Uredi Pokreni Pokreni sljedeće Dodaj u biblioteku Dodaj u sve biblioteke Ukloni iz spiska Ukloni iz biblioteke Preuzima se Preuzmi Ukloni preuzimanje Pretraži tekstove Uvezi spisku Pokaži album Dodaj ponovo Pretraži preko mreže Broj pjesama Zvuk Veličina datoteke Umjetnik pjesme ne može biti prazan. Dodaj u spisak Podijeli Izbriši Ukloni iz reda Napredno Godina Ukloni iz historije Sinkronizuj Ime Posebni red Glasnoća Nepoznato Sačuvaj Umjetnik Dužina Vrijeme puštanja Stopa uzorkovanja Kopirano u međuspremnik Odaberi spisak Izmjeni tekst Izmjeni pjesmu Umjetnik pjesme Ime spiske Preskoči duplikate Naslov pjesme ne može biti prazan. Izmjeni spisku Izmjeni umjetnika Ime spiske ne može biti prazno. Ime umjetnika Ime umjetnika ne može biti prazno. Pjesma je već u vašoj spisci Duplikati Spiska uvezena %d minut %d minute %d minuta Sviđa mi se sve Ukloni sva sviđanja Pretražene pjesme Tip proksija Ukloni \"%s\" iz spiske Spiska sinkronizovana Nepoznata greška Sviđa mi se Mjerač vremena za spavanje Kraj pjesme Nema internet veze Istek vremena Režim ponavljanja je isključen Ponavljaj trenutnu pjesmu Ponovi red Ukloni sviđanje Miješanje isključeno Muzički Plejer Sve pjesme Tema Uključi dinamičnu temu Lijevo Zadano Mračna tema Uključeno Centar Valovito Malo Prijavi se Podešavanja Izgled Veliko Ponovo pokrenite da biste vidjeli promjenu Izmjeni kartice za navigaciju Pozicija liričnog teksta Isključeno Čisto crna Plejer Poravnanje teksta plejera Desno Zadana otvorena kartica Prati sistem Na strani Izgled klizača plejera Ostalo Sadržaj Sistemski zadano Niste prijavljeni Zadani jezik sadržaja Zadana zemlja sadržaja Omogući proksiju URL Proksija Plejer i zvuk Automatski Kvalitet zvuka Visoko Izbriši historiju pretrage Nisko Automatski učitajte više pjesama Red Obnovite svoj posljednji red kada se aplikacija ponovo pokrene Automatski dodajte više pjesmih kada se red završi, ako je moguće Ekvalizator Obriši keš pjesama Uporan red Automatski preskoči do sljedeće pjesme kada dođe do greške Obezbjedite svoje neprekidno iskustvo reprodukcije Zaustavite muziku kada se procesi obrišu Obrišite sva preuzimanja Maksimalna veličina keša slika Obrišite keš slika Maksimalna veličina keša pjesama Keš slika Historija slušanja Keš pjesama Neograničeno %s korišteno Maksimalna veličina keša Pauziraj historiju slušanja Da li ste sigurni da želite da izbrišete svu historiju slušanja? Izbriši historiju slušanja Da li ste sigurni da želite da izbrišete svu historiju pretrage? Kada je opcija omogućena, slikanje zaslona i pregled aplikacije u Nedavnim je onemogućeno. Omogući LrcLib dobavljača teksta Sakrij eksplicitan sadržaj Rezervne kopije i povratak Rezervna kopija Povratak Spiska je uvezena Metrolist koristi KizzyRPC biblioteku kako bi stavio tvoj Discord status. Ovo uključuje korištenje Discord Gateway spajanja, što bi se moglo smatrati prekršajem Discord-ovog TOS-a. Međutim nema poznatih slučajeva gdje su korisnički nalozi bili suspendovani zbog ovoga razloga. Korisite na svoju štetu.\n\nMetrolist će samo izvesti vaš žeton, i sve drugo je lokalno sklađeno. Neuspješno upisivanje Opcije Pregled Omogući Bogatu Prisutnost Verzija aplikacije Izpiši se O Metrolist-u Rasčisti modele prevođenja Dostupna je nova verzija Modeli prevođenja Ovo može utjecati na sadržaj koji vidite i, na primjer, prikazivati albume koji su dostupni samo za Premium korisnike ako ste prijavljeni sa Premium računom ili nalogom Koristite prijavu za pregledavanje sadržaja ================================================ FILE: app/src/main/res/values-ca/metrolist_strings.xml ================================================ Local Remot Llistes Enrere Portada de l\'àlbum Top vídeos musicals Tendència Setmana Mesos Anys Continu Agradat Descarregat El meu top En memòria cau Sincronitza la llista Sincronització deshabitada Nota: Això permet sincronitzar amb Youtube Music. Un cop fet, això NO es podrà canviar. Generant imatge Sisplau espera Cancel·la Compartir lletres Compartir com a text Compartir com a imatge Límit màxim de selecció Compartir seleccionat Personalitzar colors Color del text Color secundari del text Color de fons Elimina de la memòria cau Còpia l\'enllaç Selecciona tot Agrada tot Desagrada a tot Data d\'actualització Enllaç copiat Començant ràdio Reproduïnt Lletres Tanca Amaga la Imatge del Reproductor Substitueix l\'art de l\'àlbum pel logotip de la app en el reproductor Ja a la llista: %d vegada %d de vegades %d vegades +%1$d segons endavant -%1$d segons enrere Cerca progressiva Si està activat, afegeix fins a 5 segons extra per cada salt de cerca Contingut similar Estil de fons del reproductor Segueix el tema Gradient Disseny nou del reproductor Disseny nou del mini reproductor Difumina Colors dels botons del reproductor Per defecte Habilita llisca per canviar de cançó Pujades Pujat Descarrega les cançons per a reproduir-les sense xarxa Elimina les cançons descarregades d\'aquesta llista Descàrrega en procés Comparteix la llista amb altres Elimina aquesta llista de manera permanent Sincronitza una llista amb YouTube Music Color primari Color terciari Llisca la cançó cap a la esquerra per a afegir-la a la cua o a la dreta per a reproduir-la a continuació Llisca la cançó per a eliminar-la de la llista Canvia la lletra polsant Lletra amb desplaçament automàtic Habilita lletra amb efecte lluent Afig animació lluent i efecte de rebot per a la lletra activa Habilita Better Lyrics Lletra sincronitzada per síl·labes per a qualsevol cançó, per a karaoke Torna a sincronitzar Prim Barra de navegació inferior prima Llista de reproducció automàtica Mostrar llista \"Agradat\" Mostrar llista \"Descarregat\" Mostrar llista \"Top\" Mostrar llista \"En memòria cau\" Mostrar llista \"Pujades\" Inicia sessió amb un token Mostrar targeta Recopilació Mescla llista/àlbum primer Quan mescles, reprodueix totes les cançons de la llista/àlbum primer, després contingut semblant Toca per mostrar el token Toca de nou per a copiar o editar Aquest és una forma d\'inici de sessió AVANÇADA. Com alternativa al portal web, vostè ha d\'introduir o actualitzat el seu token ací directament. Per exemple, aquesta acció pot accelerar l\'inici de sessió en múltiples dispositius. Tinga en compte que qualsevol format de token que la app no puga processar no serà acceptat Sincronitza automàticament amb el compte Més contingut Edita la portada de la llista Nota: Cal que el seu compte estiga enllaçat a un número de telèfon i a un compte verificat de YouTube Music per poder canviar la portada de la llista de reproducció. Després de seleccionar una imatge, per favor espere un moment per a que la nova portada es mostre en la llista de reproducció. Tria de la biblioteca Elimina imatge personalitzada General Proxy Canviar el xip de biblioteca predeterminat Establir selecció ràpida Basat en l\'última cançó escoltada Llengua de l\'aplicació Configurar proxy Usuari de proxy Contrasenya de proxy Habilitar autentificació Utilitza els detalls en lloc de l\'estat Mostra el nom de la cançó de manera destacada en lloc dels noms dels artistes Habilitar contingut similar Afegir automàticament més cançons similars quan s\'aplegue a la fi de la cua %d%% Importa una llista de reproducció \"m3u\" Importa una llista de reproducció \"csv\" Descarregar automàticament en prémer \"m\'agrada\" Descarrega automàticament les cançons quan prems el botó de \"m\'agrada\" Sensibilitat al lliscar el mini reproductor %1$d%% Està segur de que desitja eliminar les cançons de la memòria cau? Està segur de que desitja eliminar les imatges de la memòria cau? Està segur de que desitja eliminar les descàrregues? Deshabilitar No ha iniciat sessió a YouTube Obrir enllaços compatibles No s\'ha pogut obrir la configuració de l\'aplicació Notes de la versió De tots els temps Últimes 24 hores Setmana passada Mes passat Any passat Llargària de la meua llista Top Durada de l\'historial Informació Descripció Visites M\'agrada No m\'agrada Subscriure\'m Subscrit 1 segon %d de segons %d segons Deshabilitar carregar més en estar repetint tot Ciríl·lic Romanització Romanització de lletres Romanitzar lletra en japonés Romanitzar lletres en coreà Romanitzar lletres en xinés Romanitzar lletres en rus Romanitzar lletres en ucraïnés Romanitzar lletres en bielorús Romanitzar lletres en kirguís Romanitzar lletres en serbi Romanitzar lletres en búlgar EXPERIMENTAL: Detectar llengua línia per línia La llengua ciríl·lic serà detectada línia per línia en lloc de per a tota la cançó. Està segur? Romanitzar la pista actual Interfície Privacitat i Seguretat Reproductor i Contingut Emmagatzematge i Dades Sistema i Sobre Actualitzador Buscar actualitzacions automàticament Habilitar notificacions d\'actualització Actualització disponible Actualitzacions de l\'aplicació Notificacions sobre les noves versions Habilitar descàrrega (offload) Google Cast Habilitar la transmissió d\'àudio a Chromecast i altres dispositius compatibles amb Cast Romanitzar lletra en macedoni Integracions Nom d\'usuari Contrasenya Integració amb Last.fm Habilitar scrobbling Enviar Reproducció Actual Enviar Agrada/No m\'agrada Indicar que M\'agrada/No m\'agrada a Last.fm en indicar M\'agrada/No m\'agrada a Metrolist Iniciant sessió… Configuració de scrobbling Fer scrobble a cançons més llargues de Percentatge del retard de scrobble Minuts de retard de scrobble Amaga vídeos de les cançons Veure la informació de la cançó Canviar títol o artista Crear una estació basada en aquest element Afegir al començament de la cua Afegir al final de la cua Guarda a la teua biblioteca Habilita per a la reproducció sense xarxa Afig a una de les teues llistes de reproducció Obtindre les últimes metadades de YouTube Music Compartir un enllaç a aquest element Eliminar element de manera permanent Canviar tempo i to de la cançó Ajustar l\'equalitzador d\'àudio Habilitar icona dinàmica Mini Reproductor Mini reproductor negre pur Espera! Continuar Animació d\'estil paraula per paraula Cap Esvair Lluir Lliscar Karaoke Apple Music Mida del text de la lletra Espai entre línies de la lletra Portada d\'àlbum de %s Has escoltat àlbums únics El teu àlbum preferit és La teua llista de reproducció personal està llesta Els teus 5 àlbums preferits Has escoltat aquest àlbum durant %d minuts %d minuts No hi ha dades El teu artista preferit d\'enguany %d minuts Les teues cançons preferides d\'enguany Portada de l\'àlbum El teu artista preferit d\'enguany és Imatge de l\'artista preferit Has escoltat %d minuts seus La cançó que més has reproduït és L\'has escoltada durant %d minuts Has escoltat a artistes únics Has escoltat cançons diferents METROLIST És hora de veure què has estat escoltant anem! Logo de Metrolist 2025 EL TEU RECOPILATORI ESTÀ PREPARAT! És hora de veure què t\'ha encantat enguany. Gràcies per escoltar Un especial agraïment a MO Agamy per crear Metrolist Tancar Recopilatori El teu Recopilatori %s Crear llista de reproducció Llista de reproducció desada Transmetent a %s Progrés %s%% Escoltant Metrolist Obrir No s\'ha pogut generar la imatge: %s Títol copiat Artista copiat Error de reproducció Error al processar la url del proxy. Nota: No es poden afegir cançons locals a llistes sincronitzades/remotes. Qualsevol altra combinació és acceptable No carregar automàticament més cançons similars mentre està actiu el mode repetir tot Es tracta d\'una característica experimental propensa a errades.\n\nPer defecte, la llengua es determina per a tota la cançó, però amb aquesta opció, en el seu lloc, es determinarà la línia per línia. Açò permetrà que cançons multi-llengua funcionen correctament PERÒ la llengua detectada pot no ser sempre correcta (per exemple, si hi ha una lletra en ucraïnés que no conté cap caràcter específic de l\'idioma pot ser romanitzada com a russa).\n\nSi no té cap problema, es recomana mantenir aquesta opció desactivada. Utilitzar la direcció de descarrega (offload) per a la reproducció de àudio. Desactivar aquesta funció podria incrementar l\'ús d\'energia però podria ser beneficiós si està tenint problemes en la reproducció d\'àudio o en pos processat Ha triat un límit de grandària de memòria cau inferior al que l\'aplicació està utilitzant actualment (%1$s). Si continua, l\'aplicació eliminarà alguns %2$s emmagatzemats per ajustar-se al nou límit. Continuar de tota manera? Ondulat %d Perfil %d de Perfils %d Perfils Equalitzador Sense perfils d\'equalitzador Importar Perfil Deshabilitat %d grup %d de grups %d grups Eliminar Perfil Està segur de que vol eliminar %1$s? Aquesta acció no és reversible. No s\'ha pogut llegir l\'arxiu No s\'ha pogut obrir l\'arxiu: %1$s Error d\'Importació Anterior Reprodueix/Pausa Següent M\'agrada Quant a Error Coberta Equalitzador del sistema Desplaçament de la lletra Mostra\'n més Mostra\'n menys Pàgina de l\'artista Aleatori persistent Ha fallat la reproducció Activa les lletres de SimpMusic No s\'està reproduint res Omet el silenci immediatament Mostra la descripció de l\'artista Mostra el nombre de subscriptors Mostra els oients mensuals Retalla la coberta Toqueu per obrir el Metrolist Recorda l\'aleatori i la repetició Pausa la música en silenciar la multimèdia Giny reproductor de música amb controls integrats Error en aplicar el perfil d\'equalització: %1$s Lletres obtingudes automàticament de Musixmatch i YouTube Transcript Avança ràpidament les parts silencioses de les cançons Dispositiu musical circular amb controls de reproducció i m\'agrada Recorda l\'aleatori i la repetició en reiniciar l\'aplicació Mantén l\'aleatori activat en començar noves cançons o llistes Retalla les miniatures dels vídeos per forçar una relació d\'aspecte quadrada Omet les parts silencioses de les cançons en comptes d\'avançar-les ràpidament Amb reproductor expandit, mantén la pantalla encesa Escoltar Junts URL del servidor Nom d\'usuari Connectat Tornant a connectar… Desconnectat Connectant… Error de connexió Crear sala Crea una sala i comparteix el codi amb els teus amics Unir-se a una sala Codi de la sala Ets l\'amfitrió Ets un convidat Silenciar Deixar de silenciar Sol·licitud per a unir-se Veure registres Depurar la connexió i els missatges Registres de connexió Encara no hi ha registres Escolta música alhora amb els teus amics. Crea una sala per a ser l\'amfitrió o uneix-te a una sala existent amb un codi. Nota: Potser es desconnecte si crea una sala en que no s\'està reproduint música i canvia a altra aplicació. Escoltar Junts no està configurat. Per favor configura la URL del servidor en Configuració → Integracions → Escoltar Junts. %1$s ha demanat %2$s Petició enviada a l\'amfitrió! %1$s es vol unir a la sala Escoltar Junts Notificacions per a esdeveniments d\'Escoltar Junts Sala creada: %s No pot editar el nom d\'usuari estant a una sala Esperant l\'aprovació de l\'amfitrió Codi de sala invàlid Sol·licitud d\'unió rebutjada Unir-se a una sala existent Codi de la sala Abandonar la sala Unir-se Crear Unint-se a la sala %s… Creant la sala… Connectar Desconnectar Crear Unir-se Aprovar Rebutjar Buidar Copiar Copiat al porta-retalls No configurat Recepció A la sala Petició pendent Suggeriments pendents Fer una suggeriment a l\'amfitrió Fer fora Amfitrió Vostè Usuaris connectats Introduïsca el nom d\'usuari Cal el nom d\'usuari. Tornar a sincronitzar L\'aplicació ha fallat S\'ha produït un error inesperat. Per favor, enviï\'ns l\'informe de fallida per a ajudar-nos a solucionar el problema. Compartir Registres Enviar l\'informe de fallida Informe de Fallida de Metrolist Tancar No hi ha registres de fallida disponibles Cap cançó sonant Prem per obrir Metrolist Reproductor de música Tocadiscs Junts Escollir servidor Servidor personalitzat Utilitzar un servidor personalitzat Aprovar automàticament les sol·licituds d\'ingrés Aprova automàticament les sol·licituds d\'ingrés a la sala en lloc de revisar-les manualment Sincronitzar volum de l\'amfitrió Els convidats copien el nivell del volum de l\'amfitrió Introduir codi de la sala Configurar servidor, nom d\'usuari i altres Copiar codi Expulsar a aquesta persona de la sala Bloquejar de manera permanent Bloca a aquesta persona de sol·licitar unir-se i oculta els seus suggeriments Transferir Propietat Fer a aquesta persona l\'amfitrió de la sala Administrar Usuari Usuaris Blocats %d usuari(s) blocats No hi ha cap usuari blocat Desblocar Usuari blocat per l\'amfitrió Lletra traduïda per IA Traduint lletra... Lletra traduïda Proveïdor URL Base Clau de API Model Mode Traducció Llengua Destí Credencials de l\'API Traducció Transcripció No hi ha lletra que traduir No hi ha lletra És requereix una llengua objectiu Resultat de la traducció inesperat S\'ha produït un error desconegut La traducció ha fallat Dinàmica Carmesí Rosa Morat Morat Fosc Indi Blau Blau Clar Cian Blau Ànec Verd Verd Clar Llima Groc Ambre Taronja Taronja Fosc Marró Gris Gris Blavós Fons Mode Morat Pur Mode Clar Mode Fosc Mode del sistema Paleta %1$s Reproduir tot Cal una Clau de API Cal una clau de API Habilita Evita cançons repetides a la cua Quan s\'afig una cançó a la cua, elimina-la de la seua posició anterior si ja hi estava Transició suau Transició suau entre cançons Durada de la Transició suau Deshabilita els àlbums sense pauses No aplicar el transaccionat suau si hi ha pauses a l\'album Funcionalitat Beta El transicionat suau es una nova funcionalitat que pot presentar errors. Si trobes cap problema, per favor, informa\'ns.\n\nAquesta funcionalitat deshabilita la descarrega d\'àudio a causa de limitacions tècniques. Deshabilitat per que el transicionat suau està actiu Amaga Youtube Shorts Escoltar Junts a la barra superior Mostra l\'opció d\'Escoltar Junts a la barra superior de l\'aplicació en lloc de a la barra de navegació Tradueix el significat a l\'idioma objectiu Canvia la pronuncia a la llengua objectiu Obtín Claus d\'API Visita https://openrouter.ai per obtenir models gratuïts i de pagament Visita https://platform.openai.com/api-keys Visita https://console.anthropic.com/settings/keys Visita https://aistudio.google.com/apikey Visita https://perplexity.ai/settings/api Visita https://console.x.ai Visit https://deepl.com/pro-api per obtenir claus gratuïtes i de pagament Formalitat Predeterminat Més Formal Menys Formal Habilita una alta freqüència d\'actualització Força la pantalla a treballar a la màxima freqüència d\'actualització (p.e. 120Hz) Identificar Música Toca per identificar Escoltant… Processant… No s\'ha trobat cap coincidència S\'ha produït un error en identificar Prova de nou Historial de reconeixements Buidar l\'historial de reconeixements Està segur de que vol buidar tot l\'historial de reconeixements? Elimina de l\'historial Escoltar de nou Reprodueix amb Metrolist Mapatge de Columnes CSV La primera fila son les capçaleres Columna del nom de l\'Artista Columna del nom de la Cançó Columna de l\'enllaç de Youtube (Opcional) Continuar Important CSV Convertit recentment Col %d Estat En línia Inactiu No molestar Botons Botó 1 Botó 2 Sessió iniciada amb èxit! Tipus d\'activitat Reproduint Escoltant Veient Competint Variables: {nom_cançó}, {nom_artista}, {nom_album} Previsualització de la Rich Presence Presència Inicia sessió a Discord per compartir el que estàs escoltant Reproduint Metrolist Veient Metrolist Competint a Metrolist Nom de l\'activitat Nom personalitzat per a l\'activitat (buit de manera predeterminada) Mode avançat Mostra opcions de personalització avançades per a Rich Presence Sòlid Reprèn la connexió Bluetooth Romanitza lletra en Hindi Romanitza lletra en Panjabi Mostra lletres romanitzades principalment Aquesta funcionalitat utilitza la llibreria KizzyRPC per a connectar-se als ports de Discord i configurar el vostre estatus de Rich Presence. Encara que no s\'ha informat de cap suspensió de comptes per aquest tipus d\'ús, aquest mètode no està oficialment acceptat per Discord i es podria considerar una violació dels seus Termes d\'ús. El vostre token s\'extrau localment i mai s\'envia a servidors de terceres parts. Actue sota la seua pròpia consideració. Densitat de pantalla Reinicia Cal reiniciar En canvi de densitat de pantalla es farà efectiu després de reiniciar l\'aplicació. La vol reiniciar ara? Es troba a Configuració > Contingut reproduccions Accés ràpid Fixa a l\'accés ràpid Lleva l\'accés ràpid Aleatoritza l\'orde de la pantalla d\'inici Reordena aleatòriament les seccions de la pantalla d\'inici amb pesos de prioritat Sona com %1$s Perquè has escoltat %1$s Semblant a %1$s Basat en %1$s Per als seguidors de %1$s De la comunitat Base de dades de lletres sincronitzada impulsat per la comunitat Pren les lletres de KuGou, una coneguda plataforma de música Xinesa NOTA: Les lletres provinents de YouTube Music es mostraran automàticament quan no haja cap altra lletra disponible. Les lletres de YTM no solen estar sincronitzades. Habilita LyricsPlus Lletra sincronitzada de diverses fonts Selecció de proveïdor Tria quins proveïdors de lletres estan permesos Prioritat de proveïdors de lletres Arrastra per ordenar els proveïdors per preferència. Posició elevada -> proritat elevada. Registre de canvis Registre de canvis no disponible https://github.com/MetrolistGroup/Metrolist/releases Mostra a GitHub Versió actual Versió: %s Configuració d\'actualitzacions Cerca actualitzacions Cercant actualitzacions… Última: %s Cerca actualitzacions Amagar registre de canvis Veure registre de canvis No s\'ha pogut cercar actualitzacions: %s Establir com predeterminat Temporitzador de suspensió establert per defecte en %d min Habilitar temporitzador de suspensió automàtica Habilita el temporitzador de suspensió automàtica amb el valor predeterminat per un temps personalitzat Estableix un dia i hora en que el temporitzador de suspensió s\'ha d\'activar automàticament Repeteix Diari De dilluns a divendres Entre setmana / Caps de setmana Caps de setmana (Dissabte-Diumenge) Personalitzat Hora d\'inici Hora final Dilluns Dimarts Dimecres Dijous Divendres Dissabte Diumenge Para en acabar la cançó actual quan el temporitzador finalitze Esvaïment durant el minut final No s\'ha pogut desar l\'episodi No s\'ha pogut eliminar l\'episodi No s\'ha pogut subscriure\'s al podcast No s\'ha pogut llevar la subscripció al podcast Visualitzar Canal Reconeixedor de música Reconeix les cançons que sonen al teu voltant directament des de la pantalla d\'inici Prem per reconèixer la cançó Escoltant… Identificant… No s\'ha trobat cap coincidència. Prove de nou Ha fallat el reconeixement S\'ha produït un error. Per favor, prove de nou Cançó desconeguda Artista desconegut Identifica la cançó Reconeixement de música Mostra una notificació en reconèixer una cançó amb el widget Enregistrant so per identificar la cançó… Aprovar automàticament els suggeriments de cançons Aprova automàticament i afig a la cua les cançons que suggereixen els convidats Important llista de reproduccions Conservar les dades de la llibreria? Vols conservar les dades de la llista de reproducció i de la llibreria. Les cançons descarregades es mantindran de tota manera. Mantindre Eliminar Desenvolupador principal Col·laborador Col·laboradors GNU General Public License v3.0 Software gratuït i de codi lliure. Vostè el pot fer servir, estudiar, compartir i millorar. Servidor de Discord Canal de Telegram Lloc web Instagram GitHub Veure Repositori %1$s • %2$s T\'agrada el que faig? Compra\'m un cafè Comunitat & informació METROLIST Vols escoltar la teua cançó preferida? Aquest projecte dona suport a Palestina 🇵🇸 Podcasts Veure el podcast Canals de Podcast Últims episodis Els teus programes Nou episodi Episodis per a més tard Guardar per a més tard Afig als teus Episodis per a més tard Elimina de desats Desa podcast a la llibreria %d episodi %d d\'episodis %d episodis Episodis Perfils Canals Llista de reproducció automàtica Episodis baixats No t\'has subscrit a cap canal No hi ha episodis baixats %d canal %d de canals %d canals Restablir còpia de seguretat? Aquesta acció restablirà les teues dades de la aplicació de la còpia de seguretat. Hauràs de tornar a iniciar sessió destrés del restabliment. Es tancarà la sessió del següent compte: Restablir Cercant comptes anteriors… No s\'ha trobat cap compte Puja cançons Pujant… %1$d de %2$d Pujada completada La pujada ha fallat Arxiu massa gran (màxim 300MB) Format no acceptat. Empra mp3, m4a, flac o ogg Elimina cançó pujada Està segur de que vol eliminar aquesta cançó? Aquesta acció no és reversible. S\'ha eliminat la cançó pujada No s\'ha pogut eliminar la cançó pujada Eliminar cançons pujades Està segur de que vol eliminar %1$d cançons pujades. Aquesta acció no és reversible. S\'ha eliminat %1$d cançons Eliminant… ================================================ FILE: app/src/main/res/values-ca/strings.xml ================================================ Col·lecció Historial Cançons Àlbums Estadístiques Preferits oblidats Altres versions Artistes Artistes Llistes de reproducció Estats d’ànim i gèneres Compte Selecció ràpida Escolta cançons per generar la teva selecció ràpida Continua escoltant Llistes de reproducció de YouTube Similar a Nous llançaments d’àlbums Ahir Aquesta setmana Última setmana Cançons més reproduïdes Artistes més reproduïts Àlbums més reproduïts Cerca a YouTube Music… Cerca a la col·lecció… M’ha agradat Descarregat Cançons Vídeos Àlbums Llistes de la comunitat Llistes destacades Preferits No s’ha trobat cap resultat Les cançons de la col·lecció apareixeran aquí Els artistes de la col·lecció apareixeran aquí Les teves llistes de reproducció apareixeran aquí De la vostra col·lecció Cançons que m’han agradat Cançons descarregades Llista de reproducció buida Reintenta Avui Llistes de reproducció Els àlbums de la col·lecció apareixeran aquí Tot Inici Cerca De debò voleu suprimir la llista de reproducció «%s»? Ràdio Reinicialitza Detalls Edita Inicia la ràdio Reprodueix Mescla Afegeix-ho tot a la col·lecció Reprodueix a continuació Afegeix a la cua Elimina de la col·lecció Elimina-ho tot de la col·lecció Elimina la baixada Importa una llista Afegeix a una llista Visualitza l’artista Visualitza l’àlbum Comparteix Suprimeix Torna a recollir Elimina de l’historial Cerca en línia Avançat Data d’addició Nom Recompte de cançons Durada Temps de reproducció Ordre personalitzat Id. multimèdia Tipus MIME Còdecs Volum Mida del fitxer Desconegut S’ha copiat al porta-retalls Edita la lletra Velocitat de bits Velocitat de mostratge Sonoritat Cerca la lletra Edita la cançó Títol de la cançó Artistes de la cançó L’artista de la cançó no pot estar buit. El títol de la cançó no pot estar buit. Tria una llista Edita la llista Crea una llista Nom de la llista Desa Duplicades Edita l’artista Nom de l’artista El nom de l’artista no pot estar buit. El nom de la llista no pot estar buit. Ja hi ha %d cançons a la vostra llista Afegeix igualment %d cançó %d de cançons %d cançons %d artista %d d’artistes %d artistes Omet les duplicacions La cançó ja és a la vostra llista %d mes %d de mesos %d mesos %d àlbum %d d’àlbums %d àlbums %d llista %d de llistes %d llistes %d setmana %d de setmanes %d setmanes %d any %d d’anys %d anys 1 minut %d minuts %d minuts Desfés S’ha tret «%s» de la llista No s’ha trobat la lletra M’agrada No hi ha cap connexió de xarxa Mode de repetició desactivat Totes les cançons Cançons cercades Reproductor de música Repeteix la cançó actual Paràmetres Aspecte Tema Activa el tema dinàmic Tema fosc Activat Desactivat Segueix el sistema Alineació del text del reproductor Posició del text de la lletra Negre pur Personalitza les pestanyes de navegació Reproductor Per defecte Ondulat Pestanya oberta per defecte Contingut Finalitza la sessió Petita Gran Inicia la sessió Compte Llengua per defecte del contingut País per defecte del contingut Valor per defecte del sistema Activa el servidor intermediari Tipus de servidor intermediari Reproductor i àudio Qualitat de l’àudio Automàtica Baixa Cua Cua persistent Carrega més cançons automàticament Normalització de l’àudio Equalitzador Emmagatzematge Memòria cau Memòria cau d’imatges Memòria cau de cançons Il·limitada Neteja totes les baixades Privadesa Segur que voleu netejar tot l’historial d’escoltes? Historial de cerques Neteja l’historial de cerques Pausa l’historial de cerques Segur que voleu netejar tot l’historial de cerques? Desactiva les captures de pantalla Activa el proveïdor de lletres LrcLib Activa el proveïdor de lletres KuGou Fes una còpia de seguretat Restaura Llista importada Amaga el contingut explícit Còpia de seguretat i restauració Integració amb el Discord Opcions No s’ha pogut crear la còpia de seguretat No s’ha pogut restaurar la còpia de seguretat S’ha creat la còpia de seguretat satisfactòriament Previsualitza Quant a Versió de l’aplicació Models de traducció Neteja els models de traducció Hi ha una versió nova disponible S’està baixant Afegeix a la col·lecció Baixa Sincronitza Artista Elimina de la cua Elimina de la llista Tempo i to Any URL del servidor intermediari Alta %d seleccionada %d de seleccionades %d seleccionades Realment voleu eliminar totes les cançons de la llista «%s» de l’emmagatzematge de cançons baixades? S’ha importat la llista S’ha sincronitzat la llista Fi de la cançó M’agrada tot Ja no m’agrada Ja no m’agrada res Temportizador Error desconegut Reproducció aleatoria activada Reproducció aleatòria desactivada Esquerra Centre Dreta Estil del lliscador del reproductor Miscel·lània Mida de les caselles Sessió no iniciada Inici de sessió fallit Reincieu perquè els canvis tinguin efecte Recupera la última cua de reproducció quan reinicieu l\'aplicació Afegiu més cançons automàticament quan s\'arribi al final de la cua de reproducció, si és possible Saltar el silenci Continua automàticament amb la següent cancço si ocórre un error Assegura\'t una expreiència de reproducció continua Atura la música al netejar les tasques Mida màxima del cache Mida màxima del cache de les imatges Neteja el cache de les imatges Mida màxima del cache de les cançons Neteja el cache de les cançons %s utilitzat Historial de reproducció Atura la recopliació del historial de reproducció Neteja l\'historial de reproducció Pausa Repeteix la cua Utilitza iniciar sessió per cercar contingut Això pot influenciar el contingut que veus i per exemple, mostra àlbums exclusius prèmium si la sessió està iniciada en un compte prèmium Quan aquesta opció és activada, les captures de pantalla i la visualització a \"recents\" són desactivades. Metrolist utilitza la biblioteca KizzyRPC per establir l\'estat del vostre compte de Discord. Això implica utilitzar la connexió Discord Gateway, la qual cosa es pot considerar una violació de les condicions del servei de Discord. No obstant això, no hi ha casos coneguts d\'usuaris que hagin estat suspesos pel motiu. L\'ús és sota la vostra responsabilitat.\n\nMetrolist només extreu el vostre \"token\", i la resta es desa localment. Ignora Activa \"Rich Presence\" Cap transmissió disponible Amb vores ================================================ FILE: app/src/main/res/values-cs/metrolist_strings.xml ================================================ Oblíbené Offline Moje nej Vybrat vše Vše do oblíbených Datum změny Již v playlistu: Proxy Změnit výchozí stránku knihovny Nastavit rychlý výběr Podle poslední poslouchané skladby Za celou dobu Posledních 24 hodin Poslední týden Poslední měsíc Poslední rok Délka mého Seznamu nejlepších Sdílet jako obrázek Styl pozadí přehrávače Generování obrázku Maximální limit výběru Sdílet jako text Toto je POKROČILÝ způsob přihlášení. Jako alternativu k webovému portálu zde můžete přímo zadat nebo aktualizovat váš přihlašovací token. Můžete tím například urychlit přihlašování na více zařízeních. Upozorňujeme, že jakékoli neplatné formáty tokenu, které aplikace nedokáže zpracovat, nebudou přijaty Sdílet vybrané Přizpůsobit barvy Barva textu Sekundární barva textu Barva pozadí Odkaz zkopírován do schránky %dkrát %dkrát %dkrát %dkrát Podobný obsah Podle motivu Barevný přechod Rozostřený obal Barvy tlačítek přehrávače Výchozí Povolit posunutí pro změnu skladby Posuňte skladbu doleva pro přidání do fronty nebo doprava pro přehrání jako další Změnit texty po klepnutí Úzký Zúžit spodní navigační panel Automatické playlisty Zobrazit playlist „Oblíbené“ Zobrazit playlist „Stažené“ Zobrazit playlist „Nejlepší“ Zobrazit playlist „V mezipaměti“ Přihlásit se pomocí tokenu Klepněte pro zobrazení tokenu Klepněte znovu pro kopírování nebo úpravu Texty Místní Vzdálená Žebříčky Zpět Obal alba Nejlepší hudební videa Trendy Týdny Měsíce Roky Průběžné V mezipaměti Synchronizovat playlist Synchronizace zakázána Upozornění: tato funkce umožňuje synchronizaci se službou YouTube Music. Tuto možnost NELZE později změnit. Odstranit z mezipaměti Kopírovat odkaz Nelíbí se u všeho Nepodařilo se otevřít nastavení aplikace Seznam změn Čekejte prosím Zrušit Sdílet texty Obecné Jazyk aplikace Povolit podobný obsah Automaticky přidávat další podobné skladby při dosažení konce fronty %d%% Automaticky stáhnout po oblíbení Automaticky stáhnout skladby, když je přidáte do oblíbených Opravdu chcete vymazat všechny skladby v mezipaměti? Opravdu chcete vymazat všechna stahování? Nepřihlášeni do YouTube Otevírat podporované odkazy Délka historie Informace Popis Zhlédnutí Líbí se Nelíbí se 1 sekunda %d sekundy %d sekund %d sekund Automaticky posouvat texty Importovat playlisty CSV Importovat playlist „m3u“ Upozornění: přidávání místních skladeb do synchronizovaných/vzdálených playlistů není podporováno. Jakákoli jiná kombinace je platná Přepsat japonské texty do latinky Přepsat korejské texty do latinky Automaticky synchronizovat s účtem Další obsah Nový vzhled přehrávače Citlivost posunutí v mini přehrávači Opravdu chcete vymazat všechny obrázky v mezipaměti? Zakázat %1$d%% Odebírat Odebíráno Nový vzhled mini přehrávače Právě hraje +%1$d sekund vpřed -%1$d sekund zpět Progresivní posun Při povolení se při každém přeskočení bude postupně přidávat 5 sekund Zavřít Zakázat načtení více skladeb při opakování všech Automaticky nenačítat další skladby a podobný obsah, když je zapnutý režim opakování Skrýt náhled přehrávače Nahradit obal alba v přehrávači logem aplikace Rozhraní Soukromí a zabezpečení Přehrávač a obsah Úložiště a data Systém a informace Spouštím rádio Nastavit síť proxy Uživatelské jméno sítě proxy Heslo sítě proxy Povolit přihlašování Cyrilice Přepis do latinky Přepis textů do latinky Přepsat ruské texty do latinky Přepsat ukrajinské texty do latinky Přepsat běloruské texty do latinky Přepsat kyrgyzské texty do latinky Přepsat srbské texty do latinky Přepsat bulharské texty do latinky EXPERIMENTÁLNÍ: Rozpoznávat jazyk po řádcích Cyrilice bude vždy rozpoznávána řádek po řádku namísto celé skladby. Jste si jisti? Toto je experimentální funkce, která nemusí vždy fungovat správně.\n\nVe výchozím nastavení se jazyk určuje z celé písně, ale po zapnutí této možnosti se bude určovat pro každý řádek zvlášť. To umožní fungování vícejazyčných písní, ALE jazyk nemusí být vždy určen správně (například pokud ukrajinský text neobsahuje žádná písmena specifická pro ukrajinštinu, může být chybně přepsán jako ruský).\n\nPokud nemáte žádné problémy, doporučujeme nechat tuto možnost vypnutou. Přepsat aktuální skladbu do latinky Upravit obal playlistu Upozornění: pro změnu obalu alba musí být k vašemu účtu připojeno telefonní číslo a účet musí být ověřen na YouTube Music. Po vybrání obrázku prosím počkejte, než se nový obal objeví ve vašem playlistu. Vybrat z knihovny Odstranit vlastní obrázek Povolit offload Povolit offload zvukové cesty pro přehrávání zvuku. Zakázání může zvýšit spotřebu energie, ale může být užitečné, pokud máte problémy s přehráváním zvuku nebo jeho následným zpracováním Nahráno Nahráno Zobrazit playlist „Nahráno“ Aktualizační služba Automaticky kontrolovat aktualizace Přepsat makedonské texty do latinky Povolit oznámení o aktualizacích Je dostupná aktualizace Aktualizace aplikací Oznámení o nových verzích Použít podrobnosti namísto stavu Zobrazit jako hlavní název skladby namísto jména interpreta Integrace Uživatelské jméno Heslo Integrace Last.fm Povolit scrobbling Odesílat právě hrající skladbu Nastavení scrobblingu Scrobblovat skladby delší než Procento zpoždění scrobblování Minuty zpoždění scrobblování Posuňte skladbu pro odstranění z playlistu Odesílat stav oblíbení Přidat/odebrat srdíčko u skladby v Last.fm, když ji přidáte/odeberete z oblíbených v Metrolistu Přepsat čínské texty do latinky Google Cast Povolit streamování zvuku do Chromecastu a dalších zařízení kompatibilních s protokolem Cast Skrýt videoklipy Načíst aktuální metadata z YouTube Music Přidat na začátek vaší fronty Vybrali jste si limit velikosti mezipaměti menší, než kolik aplikace aktuálně používá (%1$s). Pokud budete pokračovat, může aplikace odstranit některé %2$s v mezipaměti pro splnění nového limitu. Chcete přesto pokračovat? Pokračovat Přidat na konec vaší fronty Primární barva Umožnit offline přehrávání Znovu synchronizovat Trvale odstranit tuto položku Vytvořit stanici založenou na této položce Sdílet odkaz na tuto položku Změnit název nebo umělce Zobrazit informace o skladbě Přidat do jednoho z vašich playlistů Upravit ekvalizér Pozor! Čistě černý mini přehrávač Povolit dynamickou ikonu Mini přehrávač Změnit tempo a výšku skladby Uložit do vaší knihovny Tetriární barva Přihlašování… Stáhnout všechny skladby pro offline přehrávání Odstranit všechny stažené skladby z tohoto playlistu Probíhá stahování Sdílejte tento playlist s ostatními Trvale odstranit tento playlist Synchronizovat playlist s YouTube Music Povolit Better Lyrics Texty synchronizované slovo po slově, pro karaoke Styl animace slovo po slově Žádný Přechod Záře Přesun Karaoke Apple Music Velikost textů Rozestupy řádků v textech Zamíchat nejprve playlist/album Povolit efekt záře u textů Přidat animaci záře a efekt zvětšení k aktivním textům Při náhodném přehrávání přehrát nejprve všechny skladby z původního playlistu/alba a až poté podobný obsah Zobrazit kartu Wrapped Obrázek alba %s Poslouchali jste různých alb Vaše top album je Váš osobní playlist je připraven Vašich top 5 alb Toto album jste poslouchali %d minut %d minut Žádná data Vaši top umělci roku %d minut Vaše top skladby roku Obal alba Váš top umělec roku je Obrázek top umělce Poslouchali jste ho %d minut Vaše nejpřehrávanější skladba je Poslouchali jste ji %d minut Poslouchali jste různých umělců Poslouchali jste různých skladeb METROLIST je čas podívat se, co jste poslouchali jdeme na to! Logo Metrolist 2025 TVŮJ WRAPPED JE READY! Je čas podívat se, co se ti tento rok líbilo. Děkujeme za poslouchání Zvláštní poděkování patří MO Agamy za vytvoření Metrolistu Zavřít Wrapped Váš %s Wrapped Vytvořit playlist Playlist uložen Přehrávání přes %s Postup %s%% Poslouchá Metrolist Otevřít Nepodařilo se vytvořit obrázek: %s Název zkopírován Umělec zkopírován Chyba při přehrávání Nepodařilo se zpracovat adresu proxy. %d profil %d profily %d profilů %d profilů Ekvalizér Žádné profily ekvalizéru Importovat profil Zakázáno %d pásmo %d pásma %d pásem %d pásem Odstranit profil Opravdu chcete odstranit profil %1$s? Tato akce je nevratná. Nepodařilo se přečíst soubor Nepodařilo se otevřít soubor: %1$s Chyba importu Vlnitý Pozastavit hudbu při ztlumení médií Povolit texty SimpMusic Automaticky získávané texty ze služby Musixmatch a přepisu YouTube Systémový ekvalizér Obal alba Nehraje žádná skladba Klepnutím otevřete Metrolist Předchozí Přehrát/pozastavit Další Oblíbení Widget hudebního přehrávače s ovládáním přehrávání Kruhový widget přehrávače s možnostmi ovládání přehrání a oblíbení Zapamatovat náhodné přehrávání a opakování Zapamatovat náhodné přehrávání a režim opakování při restartování aplikace Zpoždění textů O umělci Zobrazit více Zobrazit méně Stránka umělce Zobrazit popis umělce Zobrazit počet odběratelů Zobrazit počet měsíčních posluchačů Zrychlit tichá místa skladeb Okamžitě přeskočit ticho Přeskočit vpřed během tichých momentů namísto zrychlení přehrávání Perzistentní náhodné přehrávání Ponechat náhodné přehrávání povolené po spuštění nové skladby nebo playlistu Přehrávání selhalo Chyba Nepodařilo se použít profil EQ: %1$s Oříznout obal alba Vynutit čtvercový poměr stran oříznutím miniatur videí Ponechat zapnutou obrazovku při zvětšeném přehrávači Společný poslech URL adresa serveru Uživatelské jméno Připojeno Odpojeno Připojování… Chyba spojení Vytvořit místnost Vytvořte místnost a sdílejte její kód s přáteli Připojit se k místnosti Kód místnosti Žádosti o připojení Poznámka: Je možné, že budete odpojeni, pokud vytvoříte místnost bez hrající hudby a přepnete na jinou aplikaci. %1$s se chce připojit do místnosti Místnost vytvořena: %s Neplatný kód místnosti Žádost o připojení odmítnuta Připojit se k existující místnosti Kód místnosti Opustit místnost Připojit se Vytvořit Připojování k místnosti %s… Vytvářím místnost… Připojit se Odpojit se Vytvořit Obnovuji spojení… Společný poslech Oznámení pro události funkce Společný poslech Uživatelské jméno nelze změnit když jste v místnosti Připojit se Schválit Zamítnout Vymazat Kopírovat Zkopírováno do schránky Nenastaveno V místnosti Čekající žádosti Čekající návrhy Vyhodit Vy Připojení uživatelé Zadejte uživatelské jméno Uživatelské jméno je povinné. %1$s navrhl %2$s Jste hostitel Jste host Návrh byl odeslán hostiteli! Čeká se na schválení hostitelem Zobrazit logy Diagnostika připojení a zpráv Logy připojení Zatím žádné logy Poslouchejte hudbu s přáteli v reálném čase. Vytvořte místnost jako hostitel nebo se připojte k existující místnosti pomocí kódu. Společný poslech není nakonfigurován. Nastavte adresu URL serveru v Nastavení → Integrace → Společný poslech. Navrhnout hostiteli Hostitel Synchronizovat znovu Hostování místnosti Ztlumit Zrušit ztlumení Aplikace se neočekávaně ukončila Došlo k neočekávané chybě. Pošlete nám hlášení o chybě, abyste nám pomohli problém opravit. Sdílet logy Sdílet hlášení o chybě Metrolist hlášení o chybě Zavřít Není k dispozici žádný záznam o chybě Dynamická Karmínová Růžová Fialová Tmavě fialová Indigová Modrá Světle modrá Azurová Modrozelená Zelená Světle zelená Limetková Žlutá Jantarová Oranžová Tmavě oranžová Hnědá Šedá Modrošedá Zpět Režim opravdové černé Světlý režim Tmavý režim Systémový režim %1$s paleta Vybrat server Vlastní server Použít vlastní server Automaticky schvalovat žádosti o připojení Automaticky schvalovat žádosti o připojení namísto ručního schvalování Synchronizovat hlasitost s hostitelem Hlasitost hostů se řídí hlasitostí hostitele Zkopírovat kód Odebrat tohoto uživatele z místnosti Trvale zablokovat Žádosti o připojení tohoto uživatele budou zablokovány a jeho návrhy skryty Předat vlastnictví Nastaví tohoto uživatele hostitelem místnosti Spravovat uživatele Zablokování uživatelé %d uživatel(ů) zablokováno Žádní zablokování uživatelé Odblokovat Uživatel zablokován hostitelem Nehraje žádná skladba Klepněte pro otevření Metrolistu Hudební přehrávač Gramofon Vložte kód místnosti Nastavit server, uživatelské jméno a další Rozpoznat hudbu Sloupec adresy YouTube (nepovinný) Režim překladu Model Znovu poslechnout Překlad textů… Klíč API Opravdu chcete vymazat celou historii rozpoznávání? Nenalezena žádná shoda Poskytovatel Odstranit z historie Sloupec jména umělce Přemýšlím… Přepis Cílový jazyk Vymazat historii rozpoznávání Překlad Údaje API AI překlad textů Překlad selhal Mapovat sloupce CSV Společné Žádné texty k překladu Texty přeloženy Sloup. %d Texty jsou prázdné Chyba rozpoznávání Je vyžadován klíč API Došlo k neznámé chybě Vynutit použití nejvyšší podporované obnovovací frekvence displeje (např. 120 Hz) Je vyžadován cílový jazyk První řádek je hlavička Zkusit znovu Klepněte pro rozpoznání Historie rozpoznávání Povolit vysokou obnovovací frekvenci Sloupec názvu skladby Nedávno převedeno Import CSV Přehrát v Metrolistu Poslouchám… Vyžadován klíč API Neočekávaný výsledek překladu Pokračovat Základní URL Přehrát vše Povolit Prolnutí Prolnutí mezi skladbami Trvání prolnutí Zakázat pro alba bez mezer Zakázat, pokud mezi skladbami alba nejsou mezery Beta funkce Prolnutí je nová funkce, ve které se mohou vyskytnout chyby. Pokud na nějaké narazíte, prosíme nahlaste je.\n\nTato funkce zakáže offload zvuku z důvodu technických omezení. Zakázáno z důvodu aktivního prolnutí Skrýt YouTube Shorts Společný poslech v horní liště Zobrazit Společný poslech v horní liště namísto navigační lišty Jednolité Zabránit duplikovaným skladbám ve frontě Při přidání skladby do fronty ji odstranit z její předchozí pozice, pokud se ve frontě již nachází Pokračovat při připojení Bluetooth zařízení Přepsat hindské texty do latinky Přepsat pandžábské texty do latinky Zobrazit texty v latince jako hlavní Přeložit význam do cílového jazyka Převést výslovnost do cílového písma Získat klíče API Navštivte https://openrouter.ai pro bezplatné a placené modely Navštivte https://platform.openai.com/api-keys Navštivte https://console.anthropic.com/settings/keys Naštivte https://aistudio.google.com/apikey Navštivte https://perplexity.ai/settings/api Navštivte https://console.x.ai Navštivte https://deepl.com/pro-api pro bezplatné a placené klíče Formalita Výchozí Formálnější Méně formální Stav Online Nečinný Nerušit Tlačítka Tlačítko 1 Tlačítko 2 Přihlášení bylo úspěšné! Tato funkce využívá knihovnu KizzyRPC k připojení k bráně Discordu a nastavení vašeho stavu na něm. Ačkoli nejsou známy žádné případy zrušení účtu v důsledku podobného použití, není tato metoda oficiálně podporována společností Discord a může být považována za porušení podmínek služby. Váš token je extrahován lokálně a nikdy není odesílán na servery třetích stran. Postupujte podle vlastního uvážení. Typ aktivity Hraje Poslouchá Sleduje Soupeří Proměnné: {song_name}, {artist_name}, {album_name} Náhled stavu na Discordu Stav Přihlaste se k Discordu pro sdílení hudby, kterou posloucháte Hraje Metrolist Sleduje Metrolist Soupeří v Metrolist Název aktivity Vlastní název aktivity (ponechte prázdné pro výchozí) Pokročilý režim Zobrazit dodatečné možnosti přizpůsobení pro stav na Discordu Hustota displeje Restartovat Je vyžadován restart Změna hustoty displeje se použije po restartu aplikace. Chcete jej nyní provést? Najdete to v Nastavení > Obsah přehrání Rychlý výběr Připnout k Rychlému výběru Odepnout z Rychlého výběru Náhodné pořadí položek na domovské stránce Náhodně změnit uspořádání sekcí na domovské stránce s váženými prioritami Zní jako %1$s Protože jste poslouchali %1$s Podobné %1$s Založeno na %1$s Pro fanoušky %1$s Z komunity Komunitní databáze synchronizovaných textů Získává texty z KuGou, populární čínské hudební platformy Automaticky schvalovat návrhy skladeb Automaticky schvalovat a zařazovat do fronty návrhy skladeb od hostů Seznam změn Seznam změn není k dispozici https://github.com/MetrolistGroup/Metrolist/releases Zobrazit na GitHubu Aktuální verze Verze: %s Nastavení aktualizací Zkontrolovat aktualizace Kontroluji aktualizace… Nejnovější: %s Zkontrolovat aktualizace Skrýt seznam změn Zobrazit seznam změn Nepodařilo se zkontrolovat aktualizace: %s Nastavit jako výchozí Výchozí časovač spánku nastaven na %d min Ponechat data knihovny? Chcete ponechat své playlisty a data knihovny? Stažené skladby zůstanou zachovány. Ponechat Vymazat Hlavní vývojář Přispěvatel Přispěvatelé GNU General Public License verze 3.0 Volně dostupný open-source software. Můžete jej používat, studovat, sdílet a vylepšovat. Discord server Telegram kanál Web Instagram GitHub Zobrazit repozitář %1$s • %2$s Líbí se vám, co dělám? Pošlete mi kávu Komunita a informace METROLIST Chcete přehrát jejich oblíbenou skladbu? Jasně Tento projekt podporuje Palestinu 🇵🇸 Podcasty Zobrazit podcast Podcastové kanály Nejnovější epizody Vaše pořady Nové epizody Epizody na později Uložit na později Přidat do vašeho playlistu Epizody na později Odebrat z uložených Uložit podcast do knihovny %d epizoda %d epizody %d epizod %d epizod Epizody Kanály Automatický playlist Stažené epizody Žádné odebírané kanály Žádné stažené epizody %d kanál %d kanály %d kanálů %d kanálů Obnovit zálohu? Tímto se obnoví data aplikace ze zálohy. Po obnovení se budete muset znovu přihlásit. Následující účet bude odhlášen: Obnovit Kontroluji předchozí účet… Žádný účet nenalezen Importuji playlist Nepodařilo se odhlásit z podcastu Nepodařilo se přihlásit k odběru podcastu Nepodařilo se uložit epizodu Nepodařilo se odebrat epizodu UPOZORNĚNÍ: Texty z YouTube Music budou automaticky zobrazeny, když nejsou dostupné texty z jiných zdrojů. Texty z YTM obvykle nejsou synchronizovány. Povolit LyricsPlus Synchronizované texty z několika zdrojů Výběr poskytovatele Vyberte, kteří poskytovatelé textů jsou povoleni Priorita poskytovatelů textů Přesuňte pro změnu pořadí poskytovatelů. Vyšší pozice = vyšší priorita. Rozpoznávání hudby Identifikuje skladby hrající kolem vás přímo z vaší domovské obrazovky Klepněte pro identifikaci skladby Poslouchám… Identifikuji… Nenalezena žádná shoda. Zkuste to znovu Rozpoznání selhalo Došlo k chybě. Zkuste to prosím znovu Neznámá skladba Neznámý umělec Identifikovat skladbu Rozpoznání hudby Zobrazit oznámení během identifikace skladby z widgetu Nahrávání zvuku pro identifikaci skladby… Zobrazit kanál Profily Zapnout automatický časovač spánku Zapne automaticky časovač spánku s výchozí hodnotou po nastaveném čase Nastavit vlastní den a čas, kdy se časovač spánku automaticky aktivuje Opakovat Denně Od pondělí do pátku Pracovní dny / víkendy Víkendy (so–ne) Vlastní Počáteční čas Koncový čas Pondělí Úterý Středa Čtvrtek Pátek Sobota Neděle Po vypršení časovače zastavit na konci aktuální skladby Postupné ztlumení v poslední minutě Nahrát skladby Nahrávání… %1$d z %2$d Nahrávání dokončeno Nahrávání selhalo Soubor je příliš velký (max. 300 MB) Nepodporovaný formát. Použijte mp3, m4a, wma, flac nebo ogg Odstranit nahranou skladbu Opravdu chcete odstranit tuto nahranou skladbu? Tato akce je nevratná. Nahraná skladba odstraněna Nepodařilo se odstranit skladbu Odstranit nahrané skladby Opravdu chcete odstranit %1$d nahraných skladeb? Tato akce je nevratná. Odstraněno %1$d skladeb Odstraňování… Exportovat playlist Exportovat jako CSV Exportovat jako M3U Playlist úspěšně exportován Nepodařilo se exportovat playlist Sdílet Uložit do Dokumentů Rozpoznat hudbu ================================================ FILE: app/src/main/res/values-cs/strings.xml ================================================ Domů Skladby Umělci Alba Playlisty Vybrána %d Vybrány %d Vybráno %d Historie Statistiky Nálada a žánry Účet Rychlý výběr Pro vytvoření rychlého výběru si nejprve poslechněte pár skladeb Nově vydaná alba Dnes Včera Tento týden Minulý týden Nejčastěji přehrávané skladby Nejčastěji přehrávaní umělci Nejčastěji přehrávaná alba Vyhledávání Hledat v YouTube Music… Hledat v knihovně… Knihovna Oblíbené Stažené Vše Skladby Videa Alba Umělci Playlisty Komunitní playlisty Doporučené playlisty Záložky Nenalezeny žádné výsledky Z vaší knihovny Oblíbené skladby Stažené skladby Playlist je prázdný Zkusit znovu Rádio Náhodně Resetovat Podrobnosti Upravit Spustit rádio Přehrát Přehrát jako další Přidat do fronty Přidat do knihovny Odebrat z knihovny Stáhnout Stahování Odstranit ze stažených Importovat playlist Přidat do playlistu Zobrazit umělce Zobrazit album Obnovit Sdílet Odstranit Odstranit z historie Hledat online Synchronizace Pokročilé Datum přidání Název Umělec Rok Počet skladeb Délka Doba přehrávání Vlastní pořadí ID média Typ MIME Kodeky Přenosová rychlost Vzorkovací frekvence Hlučnost Hlasitost Velikost souboru Neznámé Zkopírováno do schránky Upravit texty Hledat texty Upravit skladbu Název skladby Umělci skladby Název skladby nemůže být prázdný. Umělec skladby nemůže být prázdný. Uložit Vybrat playlist Upravit playlist Vytvořit playlist Název playlistu Název playlistu nemůže být prázdný. Upravit umělce Jméno umělce Jméno umělce nemůže být prázdné. %d skladba %d skladby %d skladeb %d umělec %d umělci %d umělců %d album %d alba %d alb %d playlist %d playlisty %d playlistů %d týden %d týdny %d týdnů %d měsíc %d měsíce %d měsíců %d rok %d roky %d let Playlist importován Skladba „%s“ odebrána z playlistu Playlist synchronizován Zrušit Texty nenalezeny Časovač spánku Konec skladby 1 minuta %d minuty %d minut Není dostupný žádný stream Není dostupné připojení k internetu Vypršel čas Neznámá chyba Oblíbené Odebrat z oblíbených Náhodně zapnuto Náhodně vypnuto Režim opakování vypnut Opakovat aktuální skladbu Opakovat frontu Všechny skladby Hledané skladby Hudební přehrávač Nastavení Vzhled Povolit dynamický motiv Tmavý motiv Zap Vyp Podle systému Čistě černá Výchozí karta Přizpůsobit karty navigace Pozice textů Vlevo Uprostřed Vpravo Obsah Přihlásit se Výchozí jazyk obsahu Výchozí země obsahu Podle systému Povolit proxy Typ proxy Adresa URL proxy Restartujte pro uplatnění změn Přehrávač a zvuk Kvalita zvuku Automatická Vysoká Nízká Ukládat frontu Přeskakovat ticho Normalizace zvuku Ekvalizér Úložiště Mezipaměť Mezipaměť obrázků Mezipaměť skladeb Maximální velikost mezipaměti Neomezená Vymazat všechna stahování Maximální velikost mezipaměti obrázků Vymazat mezipaměť obrázků Maximální velikost mezipaměti skladeb Vymazat mezipaměť skladeb Využito %s Soukromí Pozastavit historii poslechu Vymazat historii poslechu Opravdu chcete vymazat celou historii poslechu? Pozastavit historii vyhledávání Vymazat historii vyhledávání Opravdu chcete vymazat celou historii vyhledávání? Povolit poskytovatele textů KuGou Záloha a obnovení Zálohovat Obnovit Playlist importován Záloha úspěšně vytvořena Nepodařilo se vytvořit zálohu Nepodařilo se obnovit zálohu O aplikaci Verze aplikace Je dostupná nová verze Modely překladů Vymazat modely překladů Odstranit z playlistu Integrace Discordu Klikatá Skladba se již nachází ve vašem playlistu Poslouchejte dál Duplikáty Na straně Velké Po zapnutí budou zakázány snímky obrazovky a náhled aplikace v Nedávných. Zde se zobrazí skladby v knihovně Různé Styl lišty přehrávače Velikost položek mřížky Odstranit z fronty Podobné Malé Historie vyhledávání Zavřít Tempo a výška Přehrávač Automaticky načíst další skladby Další verze Fronta Zapomenuté oblíbené Vaše YouTube playlisty Zde se zobrazí umělci v knihovně Zde se zobrazí alba v knihovně Zde se zobrazí vaše playlisty Opravdu chcete odstranit všech „%s“ skladeb v playlistu z úložiště Stažené skladby? Přidat vše do knihovny Odstranit vše z knihovny Přeskočit duplikáty Přesto přidat %d skladeb se již nachází ve vašem playlistu Oblíbit vše Odstranit všechny oblíbené Nejste přihlášeni Obnovit poslední frontu po spuštění aplikace Automaticky přidat více skladeb po dosažení konce fronty, pokud je to možné Automaticky přejít na další sklabdu při výskytu chyby Zajistěte si nepřetržité přehrávání Zastavit hudbu po vymazání úlohy Historie poslechu Zakázat snímky obrazovky Povolit poskytovatele textů LrcLib Skrýt explicitní obsah Možnosti Náhled Přihlášení selhalo Opravdu chcete odstranit playlist „%s“? Výchozí Motiv Zarovnání textu přehrávače Metrolist používá knihovnu KizzyRPC, aby mohl nastavit váš stav na Discordu. Tato funkce zahrnuje připojení k bráně Discord, což může být považováno za porušení podmínek společnosti Discord. Neexistují nicméně žádné známé případy uzamčení účtu z tohoto důvodu. Používejte na vlastní nebezpečí. \n \nMetrolist pouze extrahuje váš token, vše ostatní je uloženo lokálně. Povolit stav na Discordu Odhlásit se Použít účet pro procházení obsahu Může ovlivnit obsah, který se vám zobrazí – při přihlášení například mohou být zobrazena například alba, která jsou dostupná pouze s Premium účtem Přihlásit se ================================================ FILE: app/src/main/res/values-de/metrolist_strings.xml ================================================ Mag ich Heruntergeladen Meine Top Alles auswählen Alle \"Liken\" Aktualisierungsdatum Songtext Bereits in Playlist: Hintergrundstil des Players Design folgen Farbverlauf Unschärfe Wischen zum Songwechsel aktivieren Mit Tippen im Songtext springen Schmal Dünne untere Navigationsleiste Mit Token anmelden Tippen, um Token anzuzeigen Tippen, um zu kopieren oder zu bearbeiten Dies ist eine FORTGESCHRITTENE Anmeldemethode. Als Alternative zum Webportal können Sie hier Ihr Anmeldetoken direkt eingeben oder aktualisieren. Dies kann beispielsweise die Anmeldung auf mehreren Geräten beschleunigen. Bitte beachten Sie, dass ungültige Token-Formate, die die App nicht verarbeiten kann, nicht akzeptiert werden Allgemein Proxy Standard-Mediatheksauswahl ändern Schnellauswahl festlegen Basierend auf dem zuletzt gehörten Song App-Sprache Ähnliche Inhalte aktivieren Automatisch weitere ähnliche Songs hinzufügen, wenn das Ende der Warteschlange erreicht ist Unterstützte Links öffnen App-Einstellungen konnten nicht geöffnet werden Versionshinweise Gesamte Zeit Letzte 24 Stunden Letzte Woche Letzter Monat Letztes Jahr Meine Top-Liste Länge Verlauflänge Informationen Beschreibung Aufrufe Likes Dislikes 1 Sekunde %d Sekunden Nicht bei YouTube angemeldet %d%% Lokal Alle \"Disliken\" Ähnlicher Inhalt Bist du sicher dass du den Song-Cache leeren willst? Link kopieren Hinweis: Dies syncronisiert die Playlist mit YouTube Music. Das kann danach NICHT mehr geändert werden. Standard Bist du sicher dass du alle Downloads löschen willst? Monate Link in die Zwischenablage kopiert Charts Jahre Wochen Online Playlist synchronisieren Zurück Durchgängig Player-Tastenfarbe Albumcover %d Mal %d Male Im Cache Aus Cache entfernen Top Musikvideos Im Trend Wische den Song nach links, um ihn zur Warteschlange hinzuzufügen, oder nach rechts, um ihn als Nächstes abzuspielen Automatische Playlists Auswahl teilen Farben personalisieren Textfarbe Sekundäre Textfarbe Hintergrundfarbe Synchronisierung deaktiviert Bitte warten Abbrechen Songtext teilen Als Text teilen Als Bild teilen Generiere Bild Höchstgrenze der Auswahl \"Favoriten\"-Playlist anzeigen „Heruntergeladen“-Playlist anzeigen „Top“-Playlist anzeigen „Im Cache\"-Playlist anzeigen Automatisches herunterladen bei „Like“ Automatisches Scrollen des Songtextes Importieren von „m3u“-Playlists Hinweis: Lokale Songs können nicht zu synchronisierten/Online-Playlists hinzugefügt werden. Alle anderen Kombinationen sind möglich Importieren von csv-Playlists Automatisches herunterladen von Songs, wenn du sie \"Likst\" Neues Design für den Player Japanische Liedtexte romanisieren Koreanische Liedtexte romanisieren Auto-Sync mit Konto Mehr Inhalt Mini-Player-Wischsensitivität %1$d%% Bist du sicher, dass du den Bild-Cache leeren willst? Deaktivieren Abonnieren Abonniert Wird gerade abgespielt Neues Design für den Mini-Player +%1$d Sekunden vorspulen -%1$d Sekunden zurückspulen Wenn aktiviert, werden bei jedem Spulen schrittweise 5 zusätzliche Sekunden hinzugefügt Progressives Spulen Schließen Mehr Laden deaktivieren, wenn alles wiederholen aktiv ist Nicht automatisch mehr Songs und ähnliche Inhalte laden, wenn der Modus „Alles wiederholen“ aktiviert ist Albumcover im Player verbergen Ersetzt das Albumcover mit dem App-Logo im Player Benutzeroberfläche Privatsphäre & Sicherheit Player & Inhalt Speicher & Daten System & Über Radio starten Playlist-Cover bearbeiten Hinweis: Ihr Konto muss mit einer Telefonnummer verknüpft und bei YouTube Music verifiziert sein, um das Cover der Wiedergabeliste ändern zu können. Nachdem Sie ein Bild ausgewählt haben, warten Sie bitte einen Moment, bis das neue Cover in Ihrer Wiedergabeliste angezeigt wird. Von Bibliothek auswählen Benutzerdefiniertes Bild entfernen Proxy konfigurieren Proxy-Benutzername Proxy-Passwort Authentifizierung aktivieren Kyrillisch Romanisierung Romanisierung von Liedtexten Russische Liedtexte romanisieren Ukrainische Liedtexte romanisieren Belarussische Liedtexte romanisieren Kirgisische Liedtexte romanisieren Serbische Liedtexte romanisieren Bulgarische Liedtexte romanisieren EXPERIMENTELL: Sprache Zeile für Zeile erkennen Die kyrillische Sprache wird Zeile für Zeile statt für das ganze Lied erkannt. Sind Sie sicher? Dies ist eine experimentelle Funktion, deren Erfolg nicht garantiert ist.\n\nStandardmäßig wird die Sprache anhand des gesamten Liedes bestimmt, aber wenn diese Option aktiviert ist, wird sie stattdessen Zeile für Zeile bestimmt. Dadurch können mehrsprachige Lieder funktionieren, ABER die Sprache ist möglicherweise nicht immer korrekt (wenn beispielsweise ein ukrainischer Text keine ukrainischen Buchstaben enthält, wird er möglicherweise stattdessen als russisch romanisiert). \n\nWenn Sie keine Probleme haben, wird empfohlen, diese Option deaktiviert zu lassen. Aktuellen Titel romanisieren Auslagerung aktivieren Verwende den ausgelagerten Audiopfad für die Audiowiedergabe. Das Deaktivieren dieser Option kann den Stromverbrauch erhöhen, kann jedoch nützlich sein, wenn Probleme mit der Audiowiedergabe oder der Nachbearbeitung auftreten Hochgeladen Hochgeladen \"Hochgeladen\"-Playlist anzeigen Verwende Details anstelle von Statusangaben Songtitel anstelle von Künstlernamen prominent anzeigen Updater Automatisch nach Updates prüfen Benachrichtigungen zu Updates erhalten Update verfügbar App-Updates Benachrichtigungen über neue Versionen Mazedonische Liedtexte romanisieren Integrationen Benutzername Passwort Last.fm-Integration Scrobbeln aktivieren Aktuelle Wiedergabe senden Scrobbling Konfiguration Lieder scrobbeln, die länger sind als Scrobble Verzögerung (%) Scrobble Verzögerung (min) Wische den Song zur Seite, um ihn aus der Playlist zu entfernen Alle Songs für die Offline-Wiedergabe herunterladen Alle heruntergeladenen Songs aus dieser Wiedergabeliste entfernen Der Download läuft Diese Wiedergabeliste mit anderen teilen Diese Wiedergabeliste dauerhaft entfernen Playlist mit YouTube Music synchronisieren Primärfarbe Tertiärfarbe Better Lyrics-Songtext-Anbieter aktivieren Silbensynchronisierte Songtexte für jeden Song, für Karaoke Neu synchronisieren Wiedergabeliste/Album zuerst mischen Gib beim Mischen zuerst alle Songs der ursprünglichen Wiedergabeliste/des ursprünglichen Albums und dann ähnliche Inhalte wieder Wrapped-Tab anzeigen Chinesische Liedtexte romanisieren Google Cast Audioübertragung auf Chromecast und andere Cast-fähige Geräte aktivieren Likes/Dislikes senden Lieder bei Last.fm als „Gefällt mir“/„Gefällt mir nicht“ markieren, wenn sie in Metrolist als „Gefällt mir“/„Gefällt mir nicht“ markiert werden Anmelden… Videosongs ausblenden Informationen zum Song anzeigen Ändern Sie den Titel oder Interpreten Ein Radio basierend auf diesem Song erstellen Als nächstes abspielen Zum Ende Ihrer Warteschlange hinzufügen In Ihrer Bibliothek speichern Für die Offline-Wiedergabe verfügbar machen Zu einer deiner Playlisten hinzufügen Die aktuellen Metadaten von YouTube Music abrufen Link zum teilen Diese Playlist dauerhaft entfernen Tempo und Tonhöhe des Lieds ändern Den Audio-Equalizer einstellen Dynamische Symbole aktivieren Mini-Player Rein schwarzer Mini-Player Warte mal! Sie haben eine Cache-Größenbeschränkung gewählt, die kleiner ist als die derzeit von der App verwendete (%1$s). Wenn Sie fortfahren, entfernt die App möglicherweise einige zwischengespeicherte %2$s, um die neue Beschränkung einzuhalten. Möchten Sie trotzdem fortfahren? Weiter Wort-für-Wort-Animationsstil Keine Ausblenden Leuchten Gleiten Karaoke Apple Music Textgröße der Liedtexte Zeilenabstand im Liedtext Albumcover für %s Leuchtenden Text-Effekt aktivieren Leuchtanimation und Bounce-Effekt zu aktiven Textzeilen hinzufügen Dein Top-Album ist %d Minuten Keine Daten Deine Top-Künstler des Jahres %d Minuten Deine Top-Songs des Jahres Du hast dir angehört einzigartige Alben Deine persönliche Playlist ist fertig Deine Top 5 Alben Du hast dieses Album %d Minuten lang angehört Dein Top-Künstler des Jahres ist Du hast dir diesen Künstler %d Minuten lang angehört Dein meistgespielter Song ist Du hast dir diesen Song %d Minuten lang angehört einzigartige Künstler Du hast dir angehört Du hast dir angehört einzigartige Lieder METROLIST Es ist an der Zeit, herauszufinden, was du dir angehört hast Auf geht\'s! Dein Wrapped ist bereit! Zeit, einen Blick auf die Highlights des Jahres zu werfen. Danke fürs Zuhören Besonderer Dank gilt MO Agamy für die Erstellung von Metrolist Wrapped schließen Dein %s Wrapped Playlist erstellen Playlist gespeichert Übertragung auf %s Fortschritt %s%% Metrolist hören Öffnen Bild konnte nicht erstellt werden: %s Kopierter Titel Kopierter Künstler Fehler beim Abspielen Die Proxy-URL konnte nicht analysiert werden. Albumcover Metrolist Logo 2025 Bild des Top-Künstlers Wellig Musik pausieren, wenn Medien stumm geschaltet sind Equalizer Keine Equalizer-Profile Profil importieren Deaktiviert Profil löschen Möchten Sie %1$s wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. Datei konnte nicht gelesen werden Datei konnte nicht geöffnet werden: %1$s Importfehler SimpMusic-Songtext-Anbieter aktivieren Automatisch von Musixmatch und YouTube Transcript bezogene Songtexte %d Profil %d Profile System-Equalizer Es wird kein Song abgespielt Tippen, um Metrolist zu öffnen Vorherige Wiedergabe/Pause Weiter Musikplayer-Widget mit Wiedergabesteuerung %d Band %d Bänder Albumcover Kreisförmiges Musik-Widget mit Wiedergabe- und Like-Steuerung Zufallswiedergabe- und Wiederholungsmodus beim Neustart der App beibehalten Behalte den Shuffle- und Wiederholungsmodus Über Mehr anzeigen Weniger anzeigen Künstlerseite Künstlerbeschreibung anzeigen Abonnentenzahl anzeigen Monatliche Hörer anzeigen Stille sofort überspringen Springe in stillen Momenten vorwärts, anstatt die Wiedergabe zu beschleunigen Stille Teile von Songs vorspulen Albumcover zuschneiden Quadratisches Seitenverhältnis durch Zuschneiden der Video-Thumnails erzwingen Shuffle beim Starten neuer Songs oder Wiedergabelisten aktiviert lassen Fehler EQ-Profil konnte nicht angewendet werden: %1$s Wiedergabe fehlgeschlagen Permanentes shuffle Songtext versatz Display eingeschaltet lassen, wenn der Player erweitert ist Gemeinsam hören Server URL Server auswählen Benutzerdefinierter Server Benutzerdefinierten Server verwenden Benutzername Verbunden Wieder verbinden… Getrennt Verbinden… Verbindungsfehler Raum erstellen Erstelle einen Raum und teile den Code mit Freunden Raum beitreten Raumcode Du bist der Gastgeber Du bist ein Gast Stummschalten Stummschaltung aufheben Beitrittsanfragen Protokolle anzeigen Verbindungs- und Meldungsdebugs Verbindungsprotokolle Systemmodus Dunkler Modus Hellmodus Mag ich Noch keine Protokolle Automatische Genehmigung von Beitrittsanfragen Beitrittsanfragen automatisch genehmigen, anstatt sie manuell zu prüfen Lautstärke des Hosts synchronisieren Gäste passen sich der Lautstärke des Hosts an Hören gemeinsam mit deinen Freunden Musik in Echtzeit. Erstelle einen Raum, um Gastgeber zu sein, oder trete mit einem Code einem bestehenden Raum bei. Hinweis: Die Verbindung kann unterbrochen werden, wenn du einen Raum erstellst, während keine Musik abgespielt wird, und dann zu einer anderen App wechselst. Listen Together ist nicht konfiguriert. Bitte richte die Server-URL unter „Einstellungen“ → „Integrationen“ → „Listen Together“ ein. %1$s hat %2$s vorgeschlagen Vorschlag an den Host gesendet! %1$s will den Raum beitreten Gemeinsam zuhören Benachrichtigungen für Gemeinsam zuhören Ereignisse Raum erstellt: %s Benutzername kann nicht bearbeitet werden, während man sich in einem Raum befindet Warten auf Genehmigung durch den Host Ungültiger Raumcode Beitrittsanfrage abgelehnt Bestehendem Raum beitreten Raumcode Raum verlassen Beitreten Erstellen Raum %s wird beigetreten… Raum wird erstellt… Verbinden Trennen Erstellen Beitreten Genehmigen Ablehnen Löschen Kopieren In die Zwischenablage kopiert Nicht festgelegt Hosting-Raum In Raum Ausstehende Anfragen Ausstehende Vorschläge Dem Host vorschlagen Kicken Host Du Verbundene Benutzer Benutzername eingeben Benutzername ist erforderlich. Neu synchronisieren Code Kopieren Diese Person aus der Sitzung entfernen Dauerhaft blockieren Die Beitrittsanfragen dieser Person blockieren und ihre Vorschläge ausblenden Eigentum übertragen Diese Person zum Gastgeber des Raums machen Benutzer verwalten Blockierte Benutzer %d Benutzer blockiert Keine blockierten Benutzer Blockierung aufheben Benutzer vom Host gesperrt App abgestürzt Ein unerwarteter Fehler ist aufgetreten. Bitte teile uns den Absturzbericht mit, damit wir das Problem beheben können. Protokolle teilen Absturzbericht teilen Metrolist Absturzbericht Schließen Kein Absturzprotokoll verfügbar Dynamisch Purpurrot Rosa Violett Tiefes Violett Indigo Blau Himmelblau Cyan Blau grün Grün Hellgrün Gelb Orange Tiefes Orange Braun Grau Blau Grau Zurück Reines Schwarz Modus %1$s Palette Es wird kein Lied abgespielt Tippen Sie hier, um Metrolist zu öffnen Musik-Player Drehscheibe Gemeinsam Raum-Code eingeben Server, Benutzernamen und mehr konfigurieren KI-Song-Text-Übersetzung Songtexte übersetzen... Songtext übersetzt Anbieter Basis-URL API-Schlüssel Modell Übersetzungsmodus Zielsprache API-Anmeldeinformationen Übersetzung Transkription API-Schlüssel erforderlich Ein API-Schlüssel ist erforderlich Keine Songtexte zum Übersetzen Die Songtexte sind leer Die Zielsprache wird benötigt Unerwartetes Übersetzungsergebnis Es ist ein unbekannter Fehler aufgetreten Übersetzung fehlgeschlagen Bernstein Alle abspielen Musik erkennen YouTube-URL-Spalte (optional) Noch einmal zuhören Bist du sicher, dass du den gesamten Erkennungsverlauf löschen möchten? Keine Übereinstimmung gefunden Aus Verlauf löschen Spalte „Künstlername“ Verarbeitung… Lösche Erkennungs-Verlauf CSV-Spalten zuordnen Spalte %d Limette Erkennungsfehler Erzwinge, dass das Display mit der höchsten unterstützten Bildwiederholfrequenz (z. B. 120 Hz) läuft Die erste Zeile ist die Kopfzeile Versuche es erneut Zum Erkennen antippen Erkennungs-Verlauf Aktiviere eine hohe Bildwiederholrate Spalte für Songtitel Kürzlich konvertiert CSV importieren Auf Metrolist abspielen Hören… Weitermachen Aktivieren Song-Überblendung (Crossfade) Überblendung zwischen Liedern Überblenddauer Deaktivieren für lückenlose Alben Nicht Überblenden, wenn das Album lückenlos ist Beta-Funktion Song-Überblendung (Crossfade) ist eine neue Funktion und kann Fehler enthalten. Sollten Sie Probleme feststellen, melden Sie diese bitte.\n\nDiese Funktion deaktiviert die Audioauslagerung aus technischen Gründen. Deaktiviert, da Song-Überblendung (Crossfade) aktiv ist YouTube Shorts ausblenden Gemeinsam hören in der oberen Leiste anzeigen Gemeinsam hören in der oberen App-Leiste anstelle der Navigationsleiste anzeigen Einfärbig Doppelte Songs in der Warteschlange vermeiden Wird ein Titel zur Warteschlange hinzugefügt, ihn von seiner vorherigen Position entfernen, falls er bereits vorhanden ist Bei Bluetooth-Verbindung fortsetzen Hindi-Texte romanisieren Punjabi-Texte romanisieren Romanisierten Songtext als Haupttext anzeigen Bedeutung in Zielsprache übersetzen Diese Funktion verwendet die KizzyRPC-Bibliothek, um eine Verbindung zum Discord-Gateway herzustellen und Ihren Rich Presence-Status festzulegen. Obwohl keine Kontosperrungen aufgrund einer ähnlichen Nutzung bekannt sind, wird diese Methode von Discord nicht offiziell unterstützt und kann als Verstoß gegen die Nutzungsbedingungen angesehen werden. Ihr Token wird lokal extrahiert und niemals an Server Dritter gesendet. Gehen Sie nach eigenem Ermessen vor. Aktivitätstyp Umwandlung der Aussprache in das Zielskript API-Schlüssel abrufen Besuche https://openrouter.ai für kostenlose und kostenpflichtige Modelle Besuche https://platform.openai.com/api-keys Besuche https://console.anthropic.com/settings/keys Besuche https://aistudio.google.com/apikey Besuche https://perplexity.ai/settings/api Besuche https://console.x.ai Besuche https://deepl.com/pro-api für kostenlose und kostenpflichtige Schlüssel Formalität Standard Formeller Weniger formell Status Online Inaktiv Bitte nicht stören Schaltflächen Schaltfläche 1 Schaltfläche 2 Anmeldung erfolgreich! Variablen: {song_name}, {artist_name}, {album_name} Melde dich bei Discord an, um zu teilen, was du gerade hörst Aktivitätsname Benutzerdefinierter Name für die Aktivität (für Standardwert leer lassen) Erweiterter Modus Zusätzliche Personalisierungsoptionen für Rich Presence anzeigen Neustart Neustart erforderlich Anzeigedichte Die Änderung der Anzeigedichte wird nach dem Neustart der App wirksam. Möchten Sie jetzt neu starten? Zu finden unter Einstellungen > Inhalt Schnellwahl An Schnellwahl anheften Von Schnellwahl lösen Reihenfolge des Startbildschirms zufällig anordnen Die Abschnitte des Startbildschirms nach gewichteten Prioritäten zufällig neu anordnen Klingt wie %1$s Weil du %1$s hörst Ähnlich wie %1$s Basierend auf %1$s Für Fans von %1$s Aus der Community Von der Community betriebene Datenbank für synchronisierte Songtexte Nimmt Songtexte von KuGou, einer beliebten chinesischen Musikplattform HINWEIS: Songtexte von YouTube Music werden automatisch angezeigt, wenn keine anderen Songtexte verfügbar sind. Songtexte von YTM sind in der Regel nicht synchronisiert. LyricsPlus-Song-Text-Anbieter aktivieren Synchronisierte Songtexte aus mehreren Quellen Auswahl des Anbieters Wählen Sie aus, welche Anbieter von Songtexten aktiviert werden sollen Liedtext-Anbieter-Priorität Ziehen, um die Anbieter neu anzuordnen. Höhere Position -> höhere Priorität. Änderungsprotokoll Kein Änderungsprotokoll verfügbar https://github.com/MetrolistGroup/Metrolist/releases Auf GitHub anzeigen Version in Nutzung Version: %s Einstellungen aktualisieren Nach Updates suchen Nach Updates suchen … Neueste Version: %s Nach Updates suchen Änderungsprotokoll ausblenden Änderungsprotokoll anzeigen Update-Suche fehlgeschlagen: %s Als Standard einstellen Spielt Speichern der Episode fehlgeschlagen Entfernen der Episode fehlgeschlagen Abonnieren des Podcasts fehlgeschlagen Finde Lieder, die um dich herum spielen, direkt von deinem Homescreen Tippen, um Lied zu erkennen Zuhören … Erkennen … Keine Übereinstimmung gefunden. Nochmal versuchen Erkennen fehlgeschlagen Ein Fehler ist aufgetreten. Bitte erneut versuchen Unbekanntes Lied Unbekannter Künstler Lied erkennen Musik-Erkennung Musik-Erkennung Zeigt eine Benachrichtigung an, während ein Lied vom Widget erkannt wird Audio aufnehmen, um Lied zu erkennen … Lied-Vorschläge automatisch akzeptieren Lied-Vorschläge von Gästen automatisch akzeptieren und der Warteschlange hinzufügen Playlist importieren Aktuell spielt Bibliothek behalten? Willst du deine Playlists und Bibliothek-Daten behalten? Heruntergeladene Lieder werden immer behalten. Behalten Löschen Hauptentwickler Mitwirkender Mitwirkende GNU General Public License v3.0 Kostenlose Open-Source-Software. Du kannst sie nutzen, studieren, teilen und verbessern. Discord-Server Telegram-Kanal Webseite Instagram GitHub Repository anzeigen %1$s • %2$s Gefällt dir meine Arbeit? Community & Info METROLIST Den Lieblingssong der Person abspielen? Ja Dieses Projekt steht hinter Palästina 🇵🇸 Podcasts Podcast anzeigen Podcast-Kanäle Neueste Episoden Deine Shows Neue Episoden Episoden für Später Für später speichern Zu deiner Episoden für Später Playlist hinzufügen Podcast zur Bibliothek hinzufügen %d Episode %d Episoden Sicherung wiederherstellen? Das wird deine App-Daten von der Sicherung wiederherstellen. Du musst dich nach der Wiederherstellung erneut einloggen. Du wirst aus dem folgenden Account ausgeloggt: Wiederherstellen Auf vorherigen Account prüfen … Kein Account gefunden Einschlaf-Timer standardmäßig auf %d Min eingestellt Podcast-Abo kündigen fehlgeschlagen Rich Presence Vorschau Spielt Metrolist Schaut Metrolist an Im Wettkampf in Metrolist Von gespeicherten Episoden entfernen Episoden Kanäle Auto-Playlist Heruntergeladene Episoden Keine abonnierten Kanäle Keine heruntergeladenen Episoden %d Kanal %d Kanäle Täglich Montag bis Freitag Am Wochenende (Sa.–So.) Startzeit Endzeit Montags Dienstags Mittwochs Donnerstags Freitags Samstags Sontags Playlist exportieren Als CSV exportieren Als M3U exportieren Exportieren der Playliste fehlgeschlagen Automatischen Sleep-Timer aktivieren Aktiviert den Sleep-Timer automatisch mit dem Standardwert für eine benutzerdefinierte Zeit. Legen Sie einen benutzerdefinierten Tag und eine benutzerdefinierte Uhrzeit fest, zu denen der Sleep-Timer automatisch aktiviert werden soll. Wiederholen Wochentage / Wochenenden Benutzerdefiniert Am Ende des aktuellen Songs anhalten, wenn der Timer abgelaufen ist Ausblenden in der letzten Minute Kanal anzeigen Musik erkennen Zuhören Spendier mir einen Kaffee Profil Songs hochladen Wird hochgeladen… %1$d von %2$d Hochladen abgeschlossen Hochladen fehlgeschlagen Datei zu groß (max. 300 MB) Nicht unterstütztes Format. Verwenden Sie mp3, m4a, wma, flac oder ogg. Hochgeladenen Song löschen Möchten Sie diesen hochgeladenen Song wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden. Playlist export erfolgreich Teilen ================================================ FILE: app/src/main/res/values-de/strings.xml ================================================ Startseite Songs Künstler Alben Playlists %d ausgewählt %d ausgewählt Hörverlauf Statistiken Stimmungen und Genres Konto Schnellauswahl Höre dir Songs an, um deine Schnellauswahl zu erstellen Neu veröffentlichte Alben Heute Gestern Diese Woche Letzte Woche Meist gespielte Songs Meist gespielte Künstler Meist gespielte Alben Suche YouTube Music durchsuchen… Bibliothek durchsuchen… Bibliothek Mag ich Heruntergeladen Alles Songs Videos Alben Künstler Playlists Community-Playlists Ausgewählte Playlists Keine Ergebnisse gefunden Aus der Bibliothek Songs, die ich mag Heruntergeladene Songs Die Playlist ist leer Erneut versuchen Radio Shuffle Zurücksetzen Details Bearbeiten Radio starten Wiedergabe Als nächstes abspielen Zur Warteschlange hinzufügen Zur Bibliothek hinzufügen Aus Bibliothek entfernen Herunterladen Wird heruntergeladen Download entfernen Playlist importieren Zur Playlist hinzufügen Künstler ansehen Album ansehen Neu laden Teilen Löschen Aus Verlauf entfernen Online-Suche Synchronisieren Erweitert Hinzufügedatum Name Künstler Jahr Song anzahl Länge Wiedergabedauer Individuelle Reinfolge Medien-ID MIME-Typ Codecs Bitrate Abtastrate Lautstärke Lautstärke Dateigröße Unbekannt In die Zwischenablage kopiert Songtext bearbeiten Songtext suchen Song bearbeiten Songtitel Song-Künstler Der Songtitel darf nicht leer sein. Song-Künstler darf nicht leer sein. Speichern Playlist auswählen Playlist bearbeiten Playlist erstellen Name der Playlist Der Name der Playlist darf nicht leer sein. Künstler bearbeiten Name des Künstlers Der Name des Künstlers darf nicht leer sein. %d Song %d Songs %d Künstler %d Künstler %d Album %d Alben %d Playlist %d Playlists %d Woche %d Wochen %d Monat %d Monate %d Jahr %d Jahre Playlist importiert \"%s\" aus der Playlist entfernt Playlist synchronisiert Rückgängig machen Songtext nicht gefunden Schlaf-Timer Ende des Songs 1 Minute %d Minuten Kein Stream verfügbar Keine Netzwerkverbindung Zeitüberschreitung Unbekannter Fehler Mag ich „Mag ich“ entfernen Shuffle an Shuffle aus Wiederholungsmodus aus Aktuellen Song wiederholen Warteschlange wiederholen Alle Songs Gesuchte Songs Musik-Player Einstellungen Erscheinungsbild Dynamisches Thema aktivieren Dunkles Thema An Aus System-Standard Reines Schwarz Standardmäßig geöffnete Registerkarte Anpassen der Navigationsleiste Position des Songtextes Links Mitte Rechts Inhalt Anmeldung Standard-Inhaltssprache Standard-Inhaltsland System-Standard Proxy einschalten Proxy-Typ Proxy-URL Neustarten, damit Änderungen wirksam werden Player und Audio Tonqualität Automatisch Hoch Niedrig Dauerhafte Warteschlange Stille überspringen Audio-Normalisierung Equalizer Speicher Cache Bild-Cache Song-Cache Maximale Cache-Größe Unbegrenzt Alle Downloads löschen Maximale Größe des Bild-Caches Bild-Cache löschen Maximale Größe des Song-Cache Song-Cache löschen %s verwendet Privatsphäre Pausieren des Hörverlaufs Hörverlauf löschen Bist du sicher, dass du den gesamten Hörverlauf löschen willst? Suchverlauf anhalten Suchverlauf löschen Bist du sicher, dass du den gesamten Suchverlauf löschen willst? KuGou-Songtext-Anbieter aktivieren Sichern und Wiederherstellen Sichern Wiederherstellen Importierte Playlist Sicherung erfolgreich erstellt Konnte keine Sicherung erstellen Wiederherstellung der Sicherung fehlgeschlagen Über App-Version Neue Version verfügbar Übersetzungs-Modelle Lösche Übersetzungs-Modelle Vergessene Favoriten Alle „Mag ich“ entfernen Standard Automatisch mehr Songs laden Abmelden Thema Andere Versionen Musik stoppen wenn die App aus dem Hintergrund gelöscht wird Alben aus der Bibliothek werden hier angezeigt Alle zur Bibliothek hinzufügen Ähnlich wie Hörverlauf Nicht angemeldet Aus Warteschlange entfernen Duplikate Automatisch zum nächsten Song springen, wenn ein Fehler auftritt Seite Künstler aus der Bibliothek werden hier angezeigt Player Vorschau Sonstiges Trotzdem hinzufügen Stil des Player-Schiebereglers %d Songs sind bereits in deiner Playlist Statusanzeige aktivieren Sorge für ein kontinuierliches Wiedergabeerlebnis Ablehnen Ausrichtung des Player-textes Screenshots deaktivieren Klein Anmeldung fehlgeschlagen Warteschlange Duplikate überspringen Aus Playlist entfernen Tempo und Tonhöhe Optionen Groß Weiterhören Deine Youtube-Playlists Deine Playlists werden hier angezeigt Wenn diese Option aktiviert ist, sind Screenshots und die App-Ansicht in „Zuletzt gesehen“ deaktiviert. Metrolist verwendet die KizzyRPC-Bibliothek, um den Status deines Discord-Kontos zu setzen. Dazu wird die Discord-Gateway-Verbindung verwendet, was als Verstoß gegen die AGB von Discord angesehen werden kann. Es sind jedoch keine Fälle bekannt, in denen Benutzerkonten aus diesem Grund gesperrt wurden. Die Verwendung erfolgt auf eigene Gefahr.\n\nMetrolist extrahiert nur dein Token, alles andere wird lokal gespeichert. Schnörkelig Größe der Rasterzellen Suchverlauf LrcLib-Songtext-Anbieter aktivieren Explizite Inhalte ausblenden Discord-Integration Songs aus der Bibliothek werden hier angezeigt Möchtest du wirklich alle Songs der Playlist „%s“ aus dem Speicher für heruntergeladene Songs entfernen? Möchtest du die Playlist „%s“ wirklich löschen? Der Song befindet sich bereits in deiner Playlist Allen „Mag ich“ geben Alle aus Bibliothek entfernen Wiederherstellung der letzten Warteschlange beim Starten der App Automatisches Hinzufügen weiterer Songs, wenn das Ende der Warteschlange erreicht ist, sofern möglich Lesezeichen Anmeldung zum Durchsuchen von Inhalten verwenden Dies kann Einfluss darauf haben, welche Inhalte du siehst und zeigt zum Beispiel nur Premium-Alben an, wenn Sie mit einem Premium-Konto angemeldet sind Anmelden ================================================ FILE: app/src/main/res/values-el/metrolist_strings.xml ================================================ Τοπικά Απομακρυσμένα Διαγράμματα Πίσω Εξώφυλλο άλμπουμ Διάσημα Μουσικά Βίντεο Δημοφιλή Εβδομάδες Μήνες Χρόνια Συνεχής Τα liked μου Κατεβασμένα Τα Κορυφαία Μου Στην κρυφή μνήμη Συγχρονισμός Λιστών Συγχρονισμός ανενεργός Σημείωση: Αυτό επιτρέπει το συγχρονισμό με το YouTube Music. Αυτό ΔΕΝ μπορεί να αλλάξει αργότερα. Αφαίρεση από την κρυφή μνήμη Αντιγραφή συνδέσμου Επιλογή όλων Like όλα Dislike όλα Ημερομηνία ενημέρωσης Ο σύνδεσμος αντιγράφηκε στο πρόχειρο Στίχοι Ήδη σε λίστα αναπαραγωγής: %d φορά %d φορές Παρόμοιο περιεχόμενο Στυλ φόντου αναπαραγωγέα Με βάση το θέμα Διαβάθμιση Θάμπωμα Χρώματα κουμπιού παίχτη Προεπιλογή Ενεργοποίηση σάρωσης για αλλαγή τραγουδιού Σύρετε το τραγούδι προς τα δεξιά για αναπαραγωγή επόμενου ή προς τα αριστερά για προσθήκη στην ουρά Αλλαγή στίχων στο κλικ Λεπτό Απόκρυψη ετικετών της γραμμής πλοήγησης Αυτόματες Λίστες Αναπαραγωγής Εμφάνιση Αγαπημένων Λιστών Εμφάνιση Κατεβασμένων Λιστών Εμφάνιση Κορυφαίων Λιστών Εμφάνιση Λιστών στην Κρυφή Μνήμη Σύνθετη σύνδεση (token) Πατήστε για να εμφανιστεί το token Πατήστε ξανά για αντιγραφή ή επεξεργασία Αυτή είναι μια ΣΥΝΘΕΤΗ μέθοδος σύνδεσης. Ως εναλλακτική της πύλης Ιστού, μπορείτε να εισαγάγετε ή να ενημερώσετε απευθείας το token σύνδεσής σας εδώ. Για παράδειγμα, αυτό μπορεί να επιταχύνει τη σύνδεση σε πολλές συσκευές. Λάβετε υπόψη ότι τυχόν μη έγκυρες μορφές διακριτικών που η εφαρμογή αποτυγχάνει να αναλύσει δεν θα γίνουν αποδεκτές Γενικά Διακομιστής μεσολάβησης Αλλαγή προεπιλεγμένου τσιπ βιβλιοθήκης Ορισμός γρήγορων επιλογών Με βάση στο τελευταίο τραγούδι που ακούστηκε Γλώσσα εφαρμογής Ενεργοποίηση Παρόμοιου Περιεχομένου Αυτόματη προσθήκη περισσότερων παρόμοιων τραγουδιών όταν φτάσει στο τέλος της ουράς %d%% Είστε σίγουροι ότι θέλετε να διαγράψετε όλα τα τραγούδια που είναι στην κρυφή μνήμη; Είστε σίγουροι ότι θέλετε να διαγράψετε όλες τις λήψεις; Δεν έχετε συνδεθεί στο YouTube Άνοιγμα υποστηριζόμενων συνδέσμων Αδυναμία ανοίγματος των ρυθμίσεων της εφαρμογής Σημειώσεις Έκδοσης Όλη την ώρα Προηγούμενες 24 ώρες Προηγούμενη εβδομάδα Προηγούμενος μήνας Προηγούμενο έτος Μήκος της Κορυφαίας Λίστας Μου Διάρκεια ιστορικού Πληροφορίες Περιγραφή Προβολές Τα likes μου Τα dislikes μου 1 δευτερόλεπτο %d δευτερόλεπτα Χρώμα κειμένου Παρακαλώ περιμένετε Ακύρωση Κοινοποιήστε τους στοίχους Κοινή χρήση ως κείμενο Κοινή χρήση ως εικόνα Μέγιστο όριο επιλογής Κοινή χρήση επιλεγμένου Προσαρμογή χρωμάτων Δευτερεύον χρώμα κειμένου Χρώμα φόντου Δημιουργία εικόνας Αυτόματη λήψη στο like Αυτόματη λήψη τραγουδιών στο like Νέο σχέδιο mini player Στίχοι αυτόματης κύλισης Λατινοποίηση (transliteration) ιαπωνικών στίχων Λατινοποίηση (transliteration) κορεατικών στίχων Εισαγωγή λιστών αναπαραγωγής \"m3u\" Εισαγωγή λιστών αναπαραγωγής \"csv\" Σημείωση: Η προσθήκη τοπικών τραγουδιών σε συγχρονισμένες/απομακρυσμένες λίστες αναπαραγωγής δεν υποστηρίζεται. Οποιοσδήποτε άλλος συνδυασμός είναι έγκυρος Ευαισθησία ολίσθησης mini player %1$d%% Είστε βέβαιοι ότι θέλετε να διαγράψετε όλες τις εικόνες που έχουν αποθηκευτεί στην προσωρινή μνήμη; Απενεργοποίηση Εγραφή Οι εγραφές μου Περισσότερο περιεχόμενο Αυτόματος συγχρονισμός με λογαριασμό Νέος σχεδιασμός προγράμματος αναπαραγωγής Ανεβασμένα Ανεβασμένο φίλτρο Εκκίνηση ραδιοφώνου Παίζει Τώρα Κλείσιμο Απόκρυψη εικονιδίου αναπαραγωγής Αντικατάσταση εξώφυλλου άλμπουμ με το λογότυπο στο πρόγραμμα αναπαραγωγής +%1$d δευτερόλεπτα μπροστά -%1$d δευτερόλεπτα πίσω Προοδευτική αναζήτηση Εάν είναι ενεργοποιημένο, προσθέτει 5 επιπλέον δευτερόλεπτα σταδιακά σε κάθε παράλειψη αναζήτησης Σύρετε το τραγούδι για να το αφαιρέσετε από τη λίστα αναπαραγωγής Εμφάνιση \"ανεβασμένης\" λίστας αναπαραγωγής Επεξεργασία εξώφυλλου λίστας αναπαραγωγής Σημείωση: Ο λογαριασμός σας πρέπει να συνδέεται με έναν αριθμό τηλεφώνου και να επαληθευτεί στο YouTube Music για να αλλάξει το εξώφυλλο της λίστας αναπαραγωγής. Αφού επιλέξετε μια εικόνα, περιμένετε λίγο μέχρι να εμφανιστεί το νέο εξώφυλλο στη λίστα αναπαραγωγής σας. Επιλέξτε από τη βιβλιοθήκη Κατάργηση προσαρμοσμένης εικόνας Διαμόρφωση διακομιστή μεσολάβησης Όνομα χρήστη διακομιστή μεσολάβησης Κωδικός πρόσβασης διακομιστή μεσολάβησης Ενεργοποίηση ελέγχου ταυτότητας Χρήση λεπτομερειών αντί πολιτείας Προβολή τίτλου τραγουδιού με έμφαση αντί για ονόματα καλλιτεχνών Απενεργοποίηση φόρτωσης περισσότερων όταν επαναλαμβάνονται όλα Μην φορτώνετε αυτόματα περισσότερα τραγούδια και παρόμοιο περιεχόμενο όταν είναι ενεργοποιημένη η λειτουργία επανάληψης όλων Κυριλικά Greeklish Στίχοι σε Greeklish Λατινοποίηση Ρωσικών στίχων Λατινοποίηση Ουκρανικών στίχων Λατινοποίηση Λευκορωσικών στίχων Λατινοποίηση Κυργιζιανών στίχων Λατινοποίηση Σερβικών στίχων Λατινοποίηση Βουλγαρικών στίχων ΠΕΙΡΑΜΑΤΙΚΟ: Ανίχνευση γλώσσας γραμμή προς γραμμή Η κυριλλική γλώσσα θα ανιχνεύεται γραμμή προς γραμμή αντί για ολόκληρο το τραγούδι. Είστε βέβαιος; Αυτή είναι μια πειραματική λειτουργία με αβέβαιο αποτέλεσμα.\n\nΑπό προεπιλογή, η γλώσσα προσδιορίζεται από ολόκληρο το τραγούδι, αλλά με αυτήν την επιλογή ενεργοποιημένη, θα προσδιορίζεται γραμμή προς γραμμή. Αυτό θα επιτρέπει τη λειτουργία τραγουδιών σε πολλές γλώσσες, ΑΛΛΑ η γλώσσα μπορεί να μην είναι πάντα σωστή (για παράδειγμα, αν υπάρχουν ουκρανικοί στίχοι που δεν περιέχουν ουκρανικά γράμματα, μπορεί να μεταγραφούν ως ρωσικά). \n\nΕάν δεν έχετε προβλήματα, συνιστάται να διατηρήσετε αυτή την επιλογή απενεργοποιημένη. Διεπαφή Απόρρητο και ασφάλεια Αναπαραγωγέας & Περιεχόμενο Αποθήκευση & Δεδομένα Σύστημα & Σχετικά Ενημερωτής Αυτόματος έλεγχος για ενημερώσεις Ενεργοποίηση ειδοποιήσεων για ενημερώσεις Διαθέσιμη ενημέρωση Ενημερώσεις εφαρμογής Ειδοποιήσεις για νέες εκδόσεις Ενεργοποίηση εκφόρτωσης Χρησιμοποιήστε τη διαδρομή εκφόρτωσης ήχου για αναπαραγωγή ήχου. Η απενεργοποίηση αυτής της λειτουργίας μπορεί να αυξήσει την κατανάλωση ενέργειας, αλλά μπορεί να είναι χρήσιμη εάν αντιμετωπίζετε προβλήματα με την αναπαραγωγή ήχου ή την μετα-επεξεργασία Λατινοποίηση Σλαβομακεδονικών στίχων Ενσωματώσεις Όνομα χρήστη Κωδικός πρόσβασης Ενσωμάτωση Last.fm Ενεργοποίηση scrobbling Αποστολή Τρέχοντος τραγουδιού Ρύθμιση Scrobbling Σκρόμπλαρε τραγούδια μεγαλύτερα από Ποσοστό καθυστέρησης σκρομπλαρίσματος Λεπτά καθυστέρησης καταγραφής ως «scrobbled» Λατινοποίηση (transliteration) τρέχοντος κομματιού Αποστολή Likes/Unlikes Πληροφορίες καλλιτέχνη Επίδειξη περισσοτέρων Επίδειξη λιγότερων Σελίδα καλλιτέχνη Επίδειξη περιγραφής καλλιτέχνη Επίδειξη ακολούθων Επίδειξη μηνιαίων ακροατών Λήψη όλων των τραγουδιών για ακρόαση εκτός σύνδεσης Αφαίρεση όλων των ληφθέντων τραγουδιών από αυτήν την λίστα αναπαραγωγής Η λήψη είναι σε εξέλιξη Κοινή χρήση αυτής της λίστας αναπαραγωγής με άλλους Διαγραφή αυτής της λίστας αναπαραγωγής οριστικά Συγχρονισμός λίστας αναπαραγωγής με το YouTube Music Περικοπή εξώφυλλου άλμπουμ Επιβολή τετράγωνης αναλογίας διαστάσεων με περικοπή των μικρογραφιών βίντεο Κύριο χρώμα Τριτογενές χρώμα Κυματοειδές Ενεργοποίηση εφέ λαμπυρίσματος στίχων Προσθήκη κινούμενης εικόνας λάμψης και εφέ αναπήδησης σε ενεργούς στίχους Ενεργοποίηση καλύτερων στοίχων Χρησιμοποιήστε την υπηρεσία καλύτερων στοίχων για συγχρονισμένους στίχους λέξη προς λέξη Ενεργοποίηση στοίχων SimpMusic Χρήση του παρόχου στίχων SimpMusic για συγχρονισμένους στίχους Επανασυγχρονισμός Τυχαία λίστα αναπαραγωγής/άλμπουμ πρώτα Κατά την τυχαία αναπαραγωγή, παίξτε πρώτα όλα τα τραγούδια από την αρχική λίστα αναπαραγωγής/άλμπουμ και μετά παρόμοιο περιεχόμενο Εμφάνιση κάρτας Wrapped Γρήγορη προώθηση στα σιωπηλά μέρη τραγουδιών Άμεση παράλειψη σιωπής Μετάβαση μπροστά κατά τη διάρκεια των σιωπηλών στιγμών αντί να επιταχύνετε την αναπαραγωγή Διατήρηση τυχαίας αναπαραγωγής Διατήρηση της τυχαίας αναπαραγωγής κατά την έναρξη νέων τραγουδιών ή λιστών αναπαραγωγής Απομνημόνευση τυχαίας αναπαραγωγής και επανάληψης Απομνημόνευση τυχαίας αναπαραγωγής και επανάληψης κατά την επανεκκίνηση της εφαρμογής Παύση μουσικής όταν τα πολυμέσα είναι σε σίγαση Λατινοποίηση Κινέζικων στίχων Ρύθμιση Συγχρονισμού Στίχων Google Cast Ενεργοποίηση μετάδοσης ήχου σε Chromecast και άλλες συσκευές με δυνατότητα Casting Σύνδεση… Απόκρυψη τραγουδιών με βίντεο Προβολή πληροφοριών τραγουδιού Αλλαγή τίτλου ή καλλιτέχνη Δημουργία σταθμού με βάση αυτό το κομμάτι Αποθήκευση στη βιβλιοθήκη σας Να γίνει διαθέσιμο για αναπαραγωγή εκτός σύνδεσης Προσθήκη σε μια από τις λίστες αναπαραγωγής σας Λήψη των πιο πρόσφατων μεταδεδομένων από το YouTube Music Μοιραστείτε έναν σύνδεσμο για αυτό το στοιχείο Μόνιμη διαγραφή αυτού του στοιχείου Αλλαγή του τέμπο και του τόνου του τραγουδιού Ρύθμιση του ισοσταθμιστή ήχου Ενεργοποίηση δυναμικού εικονιδίου Περιμένετε! Έχετε επιλέξει ένα όριο μεγέθους προσωρινής μνήμης μικρότερο από αυτό που χρησιμοποιεί επί του παρόντος η εφαρμογή (%1$s). Εάν συνεχίσετε, η εφαρμογή ενδέχεται να καταργήσει ορισμένα %2$s για να ταιριάξει με το νέο όριο. Θέλετε να συνεχίσετε; Συνέχεια Καραόκε Apple Music Μέγεθος κειμένου στίχων Έχετε ακούσει μοναδικά άλμπουμ Το κορυφαίο σας άλμπουμ είναι το Η προσωπική σας λίστα αναπαραγωγής είναι έτοιμη Τα κορυφαία 5 άλμπουμ σας Έχετε ακούσει αυτό το άλμπουμ %d λεπτά %d λεπτά Δεν υπάρχουν δεδομένα Οι κορυφαίοι καλλιτέχνες σας της χρονιάς %d λεπτά Τα κορυφαία τραγούδια σας της χρονιάς Ο κορυφαίος καλλιτέχνης σας της χρονιάς είναι Το τραγούδι που παίζει πιο συχνά είναι Έχετε ακούσει %d λεπτά METROLIST ήρθε η ώρα να δείτε τι έχετε ακούσει Πάμε! Λογότυπο Metrolist 2025 Ήρθε η ώρα να δείτε τι λατρέψατε φέτος. Ιδιαίτερες ευχαριστίες στον MO Agamy για τη δημιουργία του Metrolist Δημιουργία λίστας αναπαραγωγής Η λίστα αναπαραγωγής αποθηκεύτηκε %d Προφίλ %d Προφίλ Ισοσταθμιστής Δεν υπάρχουν προφίλ ισοσταθμιστή Εισαγωγή Προφίλ Ισοσταθμιστής Συστήματος Απενεργοποιημένο %d συγκρότημα %d συγκροτήματα Διαγραφή Προφίλ Είστε σίγουροι ότι θέλετε να διαγράψετε το %1$s; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Δεν ήταν δυνατή η ανάγνωση του αρχείου Αποτυχία ανοίγματος αρχείου: %1$s Σφάλμα Εισαγωγής Σφάλμα Αποτυχία εφαρμογής προφίλ ισοσταθμιστή: %1$s Γίνεται casting στο %s Πρόοδος %s%% Άνοιγμα Η αναπαραγωγή απέτυχε Δεν παίζει κανένα τραγούδι Πατήστε για να ανοίξετε το Metrolist Προηγούμενο Αναπαραγωγή/Παύση Επόμενο Μου αρέσει Widget αναπαραγωγής μουσικής με χειριστήρια αναπαραγωγής Γρήγορη πρόσβαση στο πιο πρόσφατο κομμάτι που ακούσατε Διατήρηση της οθόνης ενεργοποιημένης όταν το πρόγραμμα αναπαραγωγής έχει επεκταθεί Να γίνονται τα τραγούδια Love/Unlove όταν γίνεται Like/Unlike στο Metrolist Προσθήκη στην κορυφή της σειράς αναπαραγωγής Προσθήκη στο τέλος της σειράς αναπαραγωγής Μίνι πρόγραμμα αναπαραγωγής Μίνι πρόγραμμα αναπαραγωγής σε εμφάνιση pure black Στυλ animation λέξη προς λέξη Κανένα Σβήσιμο Λάμψη Ολίσθηση Διάστημα μεταξύ των στίχων Εξώφυλλο άλμπουμ για %s Εξώφυλλο άλμπουμ Εικόνα κορυφαίου καλλιτέχνη Έχετε ακούσει για %d λεπτά Έχετε ακούσει μοναδικούς καλλιτέχνες Έχετε ακούσει μοναδικά τραγούδια Η WRAPPED ΣΑΣ ΕΙΝΑΙ ΕΤΟΙΜΗ! Σας ευχαριστούμε για την ακρόαση Κλείσιμο wrapped To Wrapped σας %s Ακούτε το Metrolist Αποτυχία δημιουργίας εικόνας: %s Ο Τίτλος Αντιγράφηκε Ο Καλλιτέχνης Αντιγράφηκε Σφάλμα αναπαραγωγής Αποτυχία ανάλυσης του url του proxy. Εξώφυλλο άλμπουμ ================================================ FILE: app/src/main/res/values-el/strings.xml ================================================ Αρχική Τραγούδια Καλλιτέχνες Άλμπουμ Λίστες %d επιλεγμένο %d επιλεγμένα Ιστορικό Στατιστικά Διάθεση και Είδη Λογαριασμός Γρήγορες επιλογές Ακούστε σε τραγούδια για να δημιουργήσετε τις γρήγορες επιλογές σας Ξεχασμένα αγαπημένα Συνέχεια ακρόασης Παρόμοια με Άλμπουμ νέας κυκλοφορίας Σήμερα Εχθές Αυτήν την εβδομάδα Τελευταία εβδομάδα Τα πιο πολυπαιγμένα τραγούδια Οι πιο πολυπαιγμένοι καλλιτέχνες Τα πιο πολυπαιγμένα άλμπουμ Αναζήτηση Αναζήτηση Μουσικής YouTube… Αναζήτηση βιβλιοθήκης… Βιβλιοθήκη Αγαπημένα Κατεβασμένα Όλα Τραγούδια Βίντεο Άλμπουμ Καλλιτέχνες Λίστες Λίστες αναπαρ. κοινότητας Προτεινόμενες λίστες αναπαραγωγής Σελιδοδείκτες Δεν βρέθηκαν αποτελέσματα Τα τραγούδια βιβλιοθήκης εμφανίζονται εδώ Οι καλλιτέχνες βιβλιοθήκης εμφανίζονται εδώ Τα άλμπουμ βιβλιοθήκης εμφανίζονται εδώ Οι λίστες αναπαραγωγής θα εμφανίζονται εδώ Από τη βιβλιοθήκη σας Αγαπημένα τραγούδια Κατεβασμένα τραγούδια Η λίστα αναπαρ. είναι κενή Θέλετε πραγματικά να αφαιρέσετε όλα τα τραγούδια της λίστας αναπαραγωγής \\\"%s\\\" από τα Ληφθέντα Τραγούδια; Θέλετε πραγματικά να διαγράψετε τη λίστα αναπαραγωγής \"%s\"; Επανάληψη Ραδιόφωνο Ανακάτεμα Επαναφορά Λεπτομέρειες Επεξεργασία Έναρξη ραδιοφώνου Αναπαραγωγή Αναπαραγωγή επόμενου Προσθήκη στην ουρά Προσθήκη στη βιβλιοθήκη Αφαίρεση από τη βιβλιοθήκη Λήψη Γίνεται λήψη Αφαίρεση λήψης Εισαγωγή λίστας Προσθήκη σε λίστα Προβολή καλλιτέχνη Προβολή άλμπουμ Ανάκτηση Κοινή χρήση Διαγραφή Αφαίρεση από το ιστορικό Αφαίρεση από λίστα Αφαίρεση από την ουρά Διαδίκτυο Συγχρονισμός Προχωρημένο Τέμπο και Τάση Ημερομηνία προσθήκης Όνομα Καλλιτέχνης Έτος Αριθμός τραγουδιών Μήκος Χρόνος παιχνιδιού Προσαρμοσμένη σειρά Id πολυμέσων Τύπος MIME Κωδικοποιητές Ρυθμός bitrate Ρυθμός δειγματοληψίας Ηχηρότητα Τόμος Μέγεθος αρχείου Άγνωστο Αντιγράφηκε στο πρόχειρο Επεξεργασία στίχων Αναζήτηση στίχων Επεξεργασία τραγουδιού Τίτλος τραγουδιού Καλλιτέχνες τραγουδιών Ο τίτλος τραγουδιού δεν μπορεί να είναι άδειος. Ο καλλιτέχνης του τραγουδιού δεν μπορεί να είναι άδειος. Αποθήκευση Επιλογή λίστας Επεξεργασία λίστας Δημιουργία λίστας Όνομα λίστας Το όνομα της λίστας αναπαραγωγής δεν μπορεί να είναι κενό. Επεξεργασία καλλιτέχνη Όνομα καλλιτέχνη Το όνομα του καλλιτέχνη δεν μπορεί να είναι κενό. Διπλότυπα Παράλειψη διπλοτύπων Προσθήκη ούτως ή άλλως Το τραγούδι είναι ήδη στη λίστα αναπαρ. σας %d τραγούδια είναι ήδη στη λίστα αναπαρ. σας %d τραγούδι %d τραγούδια %d καλλιτέχνης %d καλλιτέχνες %d άλμπουμ %d άλμπουμς %d λίστα %d λίστες %d εβδομάδα %d εβδομάδες %d μήνας %d μήνες %d έτος %d χρόνια Λίστα αναπαραγωγής εισήχθη Αφαίρεση του \"%s\" από τη λίστα Λίστα αναπαραγωγής συγχρονισμένη Αναίρεση Δεν βρέθηκαν στίχοι Χρονοδιακόπτης ύπνου Τέλος τραγουδιού 1 λεπτό %d λεπτά Δεν υπάρχει διαθέσιμη ροή Δεν υπάρχει σύνδεση δικτύου Τέλος χρονικού ορίου Άγνωστο σφάλμα Αγαπημένο Αγαπημένα όλα Αφαίρεση αγαπημένο Αφαίρεση όλων των αγαπημένων Ανακάτεμα ενεργό Ανακάτεμα ανενεργό Λειτουργία επανάληψης ανενεργό Επανάληψη τρέχοντος τραγουδιού Επανάληψη ουράς Όλα τα τραγούδια Αναζητημένα τραγούδια Αναπαραγωγέας Μουσικής Ρυθμίσεις Εμφάνιση Θέμα Ενεργοποίηση δυναμικού θέματος Σκοτεινό θέμα Ενεργό Ανενεργό Ακολουθεί το σύστημα Καθαρό μαύρο Προσαρμογή καρτελών πλοήγησης Aναπαραγωγέας Ευθυγράμμιση κειμένου αναπαραγωγέα Θέση κειμένου στίχων Πλευρά Αριστερά Κέντρο Δεξιά Στυλ ρυθμιστικού αναπαραγωγέα Προεπιλογή Στριφογυριστό Διάφορα Προεπιλεγμένη ανοικτή καρτέλα Μέγεθος κελιού πλέγματος Μικρό Μεγάλο Περιεχόμενο Σύνδεση Δεν έχετε συνδεθεί Προεπιλεγμένη γλώσσα περιεχομένου Προεπιλεγμένη χώρα περιεχομένου Προεπιλογή συστήματος Ενεργοποίηση μεσολάβησης Τύπος μεσολάβησης URL μεσολάβησης Επανεκκίνηση για να τεθεί σε ισχύ Αναπαραγωγέας και ήχος Ποιότητα ήχου Αυτόματο Υψηλή Χαμηλή Ουρά Επίμονη ουρά Επαναγορά τελευταίας ουράς κατά την εκκίνηση εφαρμογής Παράλειψη σιωπής Κανονικοποίηση ήχου Αυτόματη μετάβαση στο επόμενο τραγούδι όταν προκύψει σφάλμα Εξασφάλιση της συνεχούς εμπειρίας αναπαραγωγής σας Διακοπή μουσικής στην εργασία εκκαθάριση Ισοσταθμιστής Αποθήκευση Κρυφή μνήμη Κρυφή Μνήμη Εικόνας Κρυφή Μνήμη Τραγουδιών Μέγιστο μέγεθος κρυφής μνήμης Απεριόριστα Εκκαθάριση όλων των λήψεων Μέγιστο μέγεθος κρυφής μνήμης εικόνων Εκκαθάριση προσωρινής μνήμης εικόνας Μέγιστο μέγεθος κρυφής μνήμης τραγουδιού Εκκαθάριση προσωρινής μνήμης τραγουδιών %s χρησιμοποιείται Απόρρητο Παύση ιστορικού ακρόασης Εκκαθάριση ιστορικού ακρόασης Είστε σίγουροι ότι θέλετε να διαγράψετε όλο το ιστορικό ακροάσεων; Παύση ιστορικού αναζήτησης Εκκαθάριση ιστορικού αναζήτησης Είστε σίγουροι ότι θέλετε να διαγράψετε όλο το ιστορικό αναζήτησης; Ενεργοποίηση του παρόχου στίχων LrcLib Ενεργοποίηση παρόχου στίχων KuGou Απόκρυψη ρητού περιεχομένου Αντίγραφα ασφ. και επαναφορά Αντίγραφο ασφαλείας Επαναφορά Έγινε εισαγωγή της λίστας Δημιουργία αντιγράφου ασφ. με επιτυχία Αδυναμία δημιουργίας αντιγράφων ασφαλείας Αποτυχία επαναφοράς αντιγράφου ασφαλείας Ενσωμάτωση Discord Το Metrolist χρησιμοποιεί τη βιβλιοθήκη KizzyRPC για να ρυθμίσει την κατάσταση του λογαριασμού σας στο Discord. Αυτό περιλαμβάνει τη χρήση της σύνδεσης Discord Gateway, η οποία μπορεί να θεωρηθεί παραβίαση των TOS του Discord. Ωστόσο, δεν υπάρχουν γνωστές περιπτώσεις αναστολής λογαριασμών χρηστών για αυτόν τον λόγο. Η χρήση γίνεται με δική σας ευθύνη.\n\nΤο Metrolist θα εξάγει μόνο το token σας και όλα τα υπόλοιπα αποθηκεύονται τοπικά. Απόρριψη Επιλογές Προεπισκόπηση Η σύνδεση απέτυχε Αποσύνδεση Ενεργοποίηση Πλούσιας Παρουσίας Σχετικά Έκδοση εφαρμογής Νέα διαθέσιμη έκδοση Μοντέλα Μετάφρασης Εκκάθαριση μοντέλων μετάφρασης Αυτόματη προσθήκη περισσότερων τραγουδιών όταν το τέλος της ουράς επιτυγχάνεται, αν είναι δυνατόν Ιστορικό ακρόασης Όταν αυτή η επιλογή είναι ενεργή, τα στιγμιότυπα και η Πρόσφατη προβολή εφαρμογών θα είναι ανενεργή. Οι λίστες YouTube Άλλες εκδόσεις Αφαίρεση όλων από την βιβλιοθήκη Προσθήκη όλων στη βιβλιοθήκη Αυτόματη φόρτωση περισσότερων τραγουδιών Ιστορικό αναζήτησης Απενεργοποίηση στιγμιότυπων οθόνης Συνδεθείτε ================================================ FILE: app/src/main/res/values-en-rCA/strings.xml ================================================ Forgotten favourites Centre ================================================ FILE: app/src/main/res/values-es/metrolist_strings.xml ================================================ Local Remoto Listas de éxitos Atrás Portada del álbum Top vídeos musicales Tendencias Semanas Meses Años Continuo Canciones que me gustan Descargado Mi Top En caché Sincronizar lista de reproducción Sincronización deshabilitada Nota: Esto permite la sincronización con YouTube Music. Esto NO se puede cambiar más tarde. Eliminar de la caché Copiar enlace Seleccionar todo Me gusta a todo No me gusta a todo Fecha de actualización Enlace copiado al portapapeles Letra Ya está en la lista de reproducción: %d vez %d de veces %d veces Contenido similar Estilo de fondo del reproductor Seguir tema Gradiente Desenfoque Colores de los botones del reproductor Predeterminado Habilitar deslizar para cambiar de canción Deslizar la canción a la derecha para reproducirla a continuación o a la izquierda para añadirla a la cola Cambiar letra al hacer clic Delgado Barra de navegación inferior delgada Listas de reproducción automáticas Ver lista de reproducción \"Canciones que me gustan\" Ver lista de reproducción \"Descargado\" Ver lista de reproducción \"Top\" Ver lista de reproducción \"En caché\" Iniciar sesión con token Toca para mostrar el token Toca de nuevo para copiar o editar Este es un método de inicio de sesión AVANZADO. Como alternativa al portal web, puedes introducir o actualizar directamente tu token de inicio de sesión aquí. Por ejemplo, esto puede acelerar el inicio de sesión en algunos dispositivos. Ten en cuenta que cualquier formato de token inválido que la aplicación no pueda analizar no será aceptado General Proxy Cambiar chip de biblioteca predeterminado Establecer selecciones rápidas Basado en la última canción escuchada Idioma de la aplicación Habilitar contenido similar Añadir automáticamente más canciones similares cuando se alcance el final de la cola %d%% ¿Estás seguro de que quieres borrar todas las canciones en caché? ¿Estás seguro de que quieres borrar todas las descargas? No has iniciado sesión en YouTube Abrir enlaces compatibles No se pudieron abrir los ajustes de la aplicación Notas de la versión Desde siempre Últimas 24 horas Semana pasada Mes pasado Año pasado Longitud de la lista Mi Top Duración del historial Información Descripción Visitas Me gustas No me gustas 1 segundo %d de segundos %d segundos Por favor, espera Cancelar Compartir letra Compartir como texto Compartir como imagen Personalizar colores Color de fondo Descargar automáticamente al dar me gusta Generando imagen Color del texto Descargar canciones automáticamente al darle a me gusta Límite máximo de selección Compartir selección Color del texto secundario Desplazamiento automático de la letra Importar listas de reproducción CSV Importar listas de reproducción M3U Nota: No se permite agregar canciones locales a listas de reproducción sincronizadas o remotas. Cualquier otra combinación es válida Nuevo diseño del reproductor Romanizar letras japonesas Romanizar letras coreanas Sincronización automática con la cuenta Más contenido Sensibilidad al deslizar el mini reproductor ¿Estás seguro de que deseas borrar todas las imágenes almacenadas en caché? Deshabilitar Suscribirme Suscrito Nuevo diseño del mini reproductor %1$d%% Reproduciendo +%1$d segundos hacia adelante -%1$d segundos hacia atrás Búsqueda progresiva Si está habilitado, agrega 5 segundos adicionales de forma incremental en cada salto de búsqueda Deshabilitar cargar más al repetir todo No cargar automáticamente más canciones ni contenido similar cuando el modo repetir todo esté habilitado Cerrar Ocultar miniatura del reproductor Reemplazar la carátula del álbum con el logotipo de la aplicación en el reproductor Interfaz Privacidad y seguridad Reproductor y contenido Almacenamiento y datos Sistema y Acerca de Iniciando radio Configurar proxy Nombre de usuario proxy Contraseña de proxy Habilitar autenticación Cirílico Romanización Romanización de letras Romanizar letras en ruso Romanizar letras en ucraniano Romanizar letras en bielorruso Romanizar la letra del kirguís Romanizar letras en serbio Romanizar letras en búlgaro EXPERIMENTAL: Detectar el lenguaje línea por línea El idioma cirílico se detectará línea por línea en lugar de toda la canción. ¿Está seguro? Esta función es experimental y puede tener sus fallos.\n\nPor defecto, el idioma se determina a partir de la canción completa, pero con esta opción activada, se determinará línea por línea. Esto permitirá que las canciones multilingües funcionen, PERO el idioma podría no ser siempre correcto (por ejemplo, si hay una letra en ucraniano que no contiene letras específicas del idioma, podría romanizarse al ruso).\n\nSi no tienes problemas, se recomienda desactivar esta opción. Romanizar la pista actual Editar la portada de la lista de reproducción Nota: Tu cuenta debe estar vinculada a un número de teléfono y verificada en YouTube Music para cambiar la portada de la lista de reproducción. Después de seleccionar una imagen, espera un momento hasta que la nueva portada aparezca en la lista de reproducción. Elija de la biblioteca Eliminar imagen personalizada Activar descarga (offload) Usa la ruta de descarga de audio (offload) para la reproducción. Desactivar esta opción podría aumentar el consumo de energía, pero puede ser útil si experimentas problemas con la reproducción o el posprocesamiento de audio Subidas Subido Mostrar lista de reproducción \"Subidas\" Actualizador Buscar actualizaciones automáticamente Romanizar letras en macedonio Usar detalles en lugar de estado Mostrar el título de la canción de forma destacada en lugar de los nombres de los artistas Habilitar notificaciones de actualización Actualización disponible Actualizaciones de la app Notificaciones sobre nuevas versiones Integraciones Nombre de usuario Contraseña Integración con Last.fm Habilitar scrobbling Enviar Reproduciendo ahora Configuración de scrobbling Scrobble a canciones más largas que Porcentaje de retraso de Scrobble Minutos de retraso de Scrobble Deslizar la canción para quitarla de la lista de reproducción Enviar Me gusta/No me gusta Marcar como Me gusta o No me gusta las canciones en Last.fm cuando aparecen marcadas como Me gusta o No me gusta en Metrolist Color primario Color terciario Volver a sincronizar Transmitir con Google Cast Habilitar la transmisión de audio a Chromecast y otros dispositivos compatibles con Cast Ocultar canciones de vídeo Ver la información de la canción Cambiar el título o el artista Crea una estación basada en este elemento Añadir al inicio de la cola Añadir al final de la cola Guardar en la biblioteca Hacer disponible para reproducir sin conexión Añadir a una de tus listas de reproducción Obtener los metadatos más recientes de YouTube Music Compartir un enlace a este elemento Eliminar este elemento de forma permanente Cambiar el tempo y el tono de la canción Ajustar el ecualizador de audio Habilitar icono dinámico Minirreproductor Minirreproductor negro puro ¡Espera! Has elegido un límite de tamaño de caché menor que el que usa la aplicación (%1$s). Si continúas, la aplicación podría eliminar algunos %2$s de la caché para que coincidan con el nuevo límite. ¿Continuar de todos modos? Continuar Romanizar letras chinas Iniciando sesión… Descargar todas las canciones para reproducirlas sin conexión Eliminar todas las canciones descargadas de esta lista de reproducción La descarga está en proceso Compartir esta lista de reproducción con otros Eliminar esta lista de reproducción de forma permanente Sincronizar lista de reproducción con YouTube Music Habilitar Better Lyrics Letras sincronizadas por sílabas para cualquier canción, ideales para karaoke Estilo de animación de la letra Ninguno Tamaño del texto de la letra Aleatorizar lista de reproducción/álbum primero Al reproducir aleatoriamente, reproduce primero todas las canciones de la lista de reproducción/álbum original y luego el contenido similar Mostrar tarjeta Recap Interlineado de la letra Carátula del álbum de %s Ya has escuchado álbumes únicos Tu álbum favorito es Tu lista de reproducción personal está lista Tus 5 mejores álbumes Has escuchado este álbum durante %d minutos %d minutos No hay datos Tus mejores artistas del año %d minutos Tus mejores canciones del año Portada del álbum Tu artista favorito del año es Imagen del mejor artista Los has escuchado durante %d minutos Tu canción más reproducida es Has escuchado durante %d minutos Escuchaste a artistas únicos Escuchaste canciones únicas Es hora de ver lo que has estado escuchando ¡vamos! ¡TU RECAP ESTÁ LISTO! Es hora de ver lo que te gustó este año. Gracias por escuchar Un agradecimiento especial a MO Agamy por crear Metrolist Cerrar Recap Tu Recap %s Crear lista de reproducción Lista de reproducción guardada Transmitiendo a %s Progreso %s%% Escuchando Metrolist Abrir No se pudo crear la imagen: %s Título copiado Artista copiado Error al reproducir Error al analizar la URL del proxy. Habilitar efecto de brillo de la letra Añadir animación de brillo y efecto de rebote a la letra activa Difuminar Brillar Deslizar Karaoke Estilo Apple Music METROLIST Logo de Metrolist 2025 Mostrar descripción del artista Ver numero de suscriptores Ver oyentes mensuales Desfase de la letra Acerca de Mostrar más Mostrar menos Página del Artista Ondulado Habilitar letras de SimpMusic Letras obtenidas automáticamente de Musixmatch y YouTube Transcript Avanzar rápidamente por las partes silenciosas de las canciones Saltar el silencio al instante Salta hacia adelante durante los momentos de silencio en lugar de acelerar la reproducción Recuerda mezclar y repetir Recuerda el modo aleatorio y repetir al reiniciar la aplicación Pausar la música cuando se silencia el medio Ecualizador Sin perfiles de ecualizador Importar perfil Ecualizador del sistema Deshabilitado Eliminar Perfil ¿Está seguro de que desea eliminar %1$s? Esta acción no se puede deshacer. No se pudo leer el archivo No se pudo abrir el archivo: %1$s Error de Importación Portada del álbum No se reproduce ninguna canción Toca para abrir Metrolist Anterior Reproducir/Pausar Siguiente Me gusta Widget reproductor de música con controles de reproducción Widget musical circular con controles de reproducir y me gusta Recortar portada del álbum %d Perfil %d Perfiles %d Perfiles %d banda %d de bandas %d bandas Error Error al aplicar el perfil de ecualización: %1$s Reproducción fallida Forzar una relación de aspecto cuadrada recortando miniaturas de vídeo Aleatorio persistente Mantener la reproducción aleatoria habilitada al iniciar nuevas canciones o listas de reproducción Mantener la pantalla encendida cuando el reproductor está expandido Escuchar juntos URL del servidor Nombre de usuario Conectado Reconectando… Desconectado Conectando… Error de conexión Crear sala Crea una sala y comparte el código con tus amigos Unirse a la sala Código de la sala Eres el anfitrión Eres un invitado Solicitudes de conexión Ver registros Depurar conexión y mensajes Registros de conexión Aún no hay registros Escucha música con tus amigos en tiempo real. Crea una sala para ser el anfitrión o únete a una sala existente con un código. Nota: Es posible que se desconecte si crea una sala mientras no se está reproduciendo música y luego cambia a otra aplicación. Listen Together no está configurado. Configure la URL del servidor en Configuración → Integraciones → Listen Together. %1$s solicitó %2$s Sugerencia enviada al anfitrión! %1$s quiere unirse a la sala Escuchar juntos Notificaciones de eventos de Escuchar juntos Sala creada: %s No se puede editar el nombre de usuario mientras se está en una sala Esperando la aprobación del anfitrión Código de sala no válido Solicitud de unión denegada Unirse a una sala existente Código de sala Salir de la sala Unirse Crear Uniéndose a la sala %s… Creando sala… Conectar Desconectar Crear Unirse Aprobar Rechazar Limpiar Copiar Copiado al portapapeles No establecido Alojar sala En sala Solicitudes pendientes Sugerencias pendientes Sugerir alojar Expulsar Anfitrión Usuarios conectados Ingresar nombre de usuario Se requiere nombre de usuario. Re sincronizar Silenciar Activar sonido La app falló Ocurrió un error inesperado. Por favor comparte el informe de fallos para ayudarnos a solucionar el problema. Compartir registros Compartir informe de fallos Informe de fallos de Metrolist Cerrar No hay registro de fallos disponible Dinámico Carmesí Rosa Morado Púrpura intenso Índigo Azul Celeste Cian Verde azulado Verde Verde claro Lima Amarillo Ámbar Naranja Naranja intenso Marrón Gris Gris azulado Atrás Modo negro puro Modo claro Modo oscuro Modo del sistema Paleta %1$s No hay canción en reproducción Toca para abrir Metrolist Reproductor de música Tocadiscos Elegir servidor Servidor personalizado Usar servidor personalizado Aprobar automáticamente solicitudes de conexión Aprobar automáticamente solicitudes de conexión en lugar de revisarlas manualmente Sincronizar volumen del anfitrión Los invitados siguen el nivel de volumen del anfitrión Copiar código Eliminar a esta persona de la sesión Bloquear permanentemente Bloquear las solicitudes de unión de esta persona y ocultar sus sugerencias Transferir propiedad Convertir a esta persona en el anfitrión de la sala Administrar usuario Usuarios bloqueados %d usuario(s) bloqueado(s) No hay usuarios bloqueados Desbloquear Usuario bloqueado por el anfitrión Juntos Ingresar código de sala Configurar servidor, nombre de usuario y más Traducción de letras con IA Traduciendo letras... Letras traducidas Proveedor URL base Clave API Modelo Modo de traducción Idioma de destino Credenciales de API Traducción Transcrito Clave API requerida Clave API obligatoria No hay letras para traducir Las letras están vacías El idioma de destino es obligatorio Resultado de traducción inesperado Error desconocido ocurrido Traducción fallida Reproducir todo Identificar música Columna de URL de YouTube (Opcional) Volver a escuchar ¿Estás seguro de que quieres borrar todo el historial de identificación? No se encontró ninguna coincidencia Eliminar del historial Columna de nombre del artista Procesando… Borrar historial de identificación Mapear columnas CSV Col %d Error de identificación Forzar a la pantalla a funcionar a la frecuencia de actualización más alta permitida (p. ej., 120Hz) La primera fila es el encabezado Inténtalo de nuevo Toca para identificar Historial de identificación Habilitar alta frecuencia de actualización Columna de título de la canción Recientemente convertidos Importando CSV Reproducir en Metrolist Escuchando… Continuar Habilitar Transición suave entre canciones Duración de la transición suave Desactivar para álbumes sin pausas No hacer transición suave si el álbum es sin pausas Función beta La transición suave es una función nueva y puede tener errores. Si experimentas algún problema, por favor repórtalo. \n\nEsta función desactiva la descarga de audio debido a limitaciones técnicas. Desactivado porque la transición suave está activa Transición suave Ocultar YouTube Shorts Escuchar juntos en la barra superior Mostrar Escuchar juntos en la barra superior en lugar de en la barra de navegación Evitar pistas duplicadas en la cola Al agregar una pista a la cola, elimínela de su posición anterior si ya está presente Convertir pronunciación al script de destino Obtener Llaves API Status Online Compitiendo Presencia Sólido Reanudar al conectar Bluetooth Romanizar letras en hindi Romanizar letras en panyabí Mostrar letras romanizadas como principales Traducir el significado al idioma de destino Visita https://openrouter.ai para modelos gratuitos y de pago Visita https://platform.openai.com/api-keys Visita https://console.anthropic.com/settings/keys Visita https://aistudio.google.com/apikey Visita https://perplexity.ai/settings/api Visita https://console.x.ai Visita https://deepl.com/pro-api para claves gratuitas y de pago Formalidad Predeterminado Más formal Menos formal Ausente No molestar Botones Botón 1 Botón 2 ¡Inicio de sesión con éxito! Esta función utiliza la biblioteca KizzyRPC para conectarse a la pasarela (Gateway) de Discord y establecer tu estado de Rich Presence. Aunque no se conocen suspensiones de cuentas por un uso similar, este método no cuenta con el soporte oficial de Discord y podría considerarse una infracción de sus Términos de Servicio. Tu token se extrae localmente y nunca se envía a servidores de terceros. Procede bajo tu propia responsabilidad. Tipo de actividad Jugando Escuchando Viendo Variables: {song_name}, {artist_name}, {album_name} Vista previa de Rich Presence Inicia sesión con Discord para compartir lo que estás escuchando Reproduciendo Metrolist Viendo Metrolist Compitiendo en Metrolist Nombre de la actividad Nombre personalizado para la actividad (dejar vacío para el valor predeterminado) Modo avanzado Mostrar opciones de personalización adicionales para Rich Presence Densidad de pantalla Reiniciar Se requiere reiniciar El cambio en la densidad de pantalla se aplicará después de reiniciar la aplicación. ¿Quieres reiniciarla ahora? Se encuentra en Ajustes > Contenido reproducciones Acceso rápido Fijar al acceso rápido Quitar del acceso rápido Aleatorizar el orden de la pantalla de inicio Reordenar aleatoriamente las secciones de la pantalla de inicio según prioridades ponderadas Suena como %1$s Porque escuchas a %1$s Similar a %1$s Basado en %1$s Para fans de %1$s De la comunidad Base de datos de letras sincronizadas impulsada por la comunidad Obtiene las letras de KuGou, una popular plataforma de música china NOTA: Las letras de YouTube Music se mostrarán automáticamente cuando no haya otras letras disponibles. Las letras de YTM no suelen estar sincronizadas. Activar LyricsPlus Letras sincronizadas de múltiples fuentes Selección de proveedor Elige qué proveedores de letras están activados Prioridad de proveedores de letras Arrastra para reordenar los proveedores por preferencia. Una posición más alta indica una mayor prioridad. Registro de cambios No hay registros de cambios disponibles https://github.com/MetrolistGroup/Metrolist/releases Ver en GitHub Versión actual Versión: %s Ajustes de actualización Buscar actualizaciones Buscando actualizaciones… Última: %s Buscar actualizaciones Ocultar registro de cambios Ver registro de cambios Error al buscar actualizaciones: %s Establecer como predeterminado Temporizador de apagado predeterminado establecido en %d min No se pudo guardar el episodio No se pudo eliminar el episodio Error al suscribirse al pódcast Error al cancelar la suscripción al pódcast Aprobar automáticamente las sugerencias de canciones Aprobar automáticamente y añadir a la cola las sugerencias de canciones de invitados Importando lista de reproducción ¿Conservar los datos de la biblioteca? ¿Quieres conservar tus listas de reproducción y los datos de tu biblioteca? Las canciones descargadas se conservarán igualmente. Conservar Borrar Desarrollador principal Colaborador Colaboradores GNU General Public License v3.0 Software libre y de código abierto. Puedes usarlo, estudiarlo, compartirlo y mejorarlo. Servidor de Discord Canal de Telegram Sitio web Instagram GitHub Ver repositorio %1$s • %2$s ¿Te gusta lo que hago? Invítame un café Comunidad e información METROLIST ¿Quieres reproducir su canción favorita? Este proyecto apoya a Palestina 🇵🇸 Podcasts Ver podcast Canales de podcast Últimos episodios Tus programas Nuevos episodios Episodios para más tarde Guardar para más tarde Añadir a tu lista de Episodios para más tarde Quitar de guardados Guardar podcast en la biblioteca %d episodio %d episodios %d episodios ¿Restaurar copia de seguridad? Esto restaurará los datos de la aplicación desde la copia de seguridad. Deberás iniciar sesión de nuevo tras la restauración. Se cerrará la sesión de la siguiente cuenta: Restaurar Buscando una cuenta anterior… No se encontró ninguna cuenta Identificador de música Identifica canciones que suenan a tu alrededor directamente desde tu pantalla de inicio Toca para identificar la canción Escuchando… Identificando… No se encontraron coincidencias. Inténtalo de nuevo Identificación fallida Ocurrió un error. Inténtalo de nuevo Canción desconocida Artista desconocido Identificar canción Identificación de música Muestra una notificación mientras se identifica una canción desde el widget Grabando audio para identificar la canción… Episodios Canales Lista de reproducción automática Episodios descargados No hay canales suscritos No hay episodios descargados %d canal %d canales %d canales Ver canal Perfiles Activar temporizador de apagado automático Activa automáticamente el temporizador de apagado usando el valor predeterminado a una hora personalizada Establece un día y una hora personalizados para que el temporizador de apagado se active automáticamente Repetir Diario De lunes a viernes Entre semana / Fin de semana Fines de semana (sáb–dom) Personalizado Hora de inicio Hora de fin Lunes Martes Miércoles Jueves Viernes Sábado Domingo Detener al final de la canción actual cuando termine el temporizador Atenuar el volumen en el último minuto Subir canciones Subiendo… %1$d de %2$d Subida completada Subida fallida Archivo demasiado grande (máx. 300 MB) Formato no compatible. Usa mp3, m4a, wma, flac u ogg Eliminar canción subida ¿Seguro que quieres eliminar esta canción subida? Esta acción no se puede deshacer. Canción subida eliminada No se pudo eliminar la canción subida Eliminar canciones subidas ¿Seguro que quieres eliminar %1$d canciones subidas? Esta acción no se puede deshacer. Se eliminaron %1$d canciones Eliminando… Exportar lista de reproducción Exportar como CSV Exportar como M3U Lista de reproducción exportada correctamente No se pudo exportar la lista de reproducción Compartir Guardar en Documentos Identificar música ================================================ FILE: app/src/main/res/values-es/strings.xml ================================================ Inicio Canciones Artistas Álbumes Listas de reproducción %d seleccionada %d de seleccionadas %d seleccionadas Historial Estadísticas Estado de ánimo y géneros Cuenta Selecciones rápidas Escuche canciones para generar sus selecciones rápidas Favoritos olvidados Seguir escuchando Similares a Álbumes recién lanzados Hoy Ayer Esta semana La semana pasada Canciones más reproducidas Artistas más reproducidos Álbumes más reproducidos Buscar Buscar en YouTube Music… Buscar en la biblioteca… Biblioteca Me gusta Descargado Todo Canciones Vídeos Álbumes Artistas Listas de reproducción Listas de la comunidad Listas destacadas En marcadores No se han encontrado resultados Las canciones de la biblioteca aparecerán aquí Los artistas de la biblioteca aparecerán aquí Los álbumes de la biblioteca aparecerán aquí Tus listas de reproducción aparecerán aquí De tu biblioteca Canciones que te gustan Canciones descargadas La lista de reproducción está vacía ¿Realmente quiere quitar todas las canciones de la lista de reproducción «%s» del almacenamiento de canciones descargadas? ¿Confirma que quiere eliminar la lista de reproducción «%s»? Reintentar Radio Aleatorio Restablecer Detalles Editar Iniciar radio Reproducir Reproducir a continuación Añadir a la cola Añadir a la biblioteca Quitar de la biblioteca Descargar Descargando Eliminar descarga Importar lista de reproducción Añadir a lista de reproducción Ver artista Ver álbum Cargar de nuevo Compartir Eliminar Eliminar del historial Quitar de la lista de reproducción Quitar de la cola Buscar en línea Sincronizar Avanzado Tempo y tono Fecha añadida Nombre Artista Año Número de canciones Duración Tiempo de reproducción Orden personalizado ID multimedia Tipo de MIME Códecs Tasa de bits Frecuencia Intensidad Volumen Tamaño del archivo Desconocido Copiado al portapapeles Editar letra Buscar letra Editar canción Título Artistas El título de la canción no puede estar vacío. El artista de la canción no puede estar vacío. Guardar Elegir lista de reproducción Editar lista de reproducción Crear lista de reproducción Nombre de la lista de reproducción El nombre de la lista de reproducción no puede estar vacío. Editar artista Nombre del artista El nombre del artista no puede estar vacío. Duplicados Saltar duplicados Añadir de todas formas Esta canción ya está en su lista de reproducción %d canciones ya están en su lista de reproducción %d canción %d de canciones %d canciones %d artista %d de artistas %d artistas %d álbum %d de álbumes %d álbumes %d lista de reproducción %d listas de reproducción %d listas de reproducción %d semana %d de semanas %d semanas %d mes %d de meses %d meses %d año %d de años %d años Lista de reproducción importada Eliminado «%s» de la lista de reproducción Lista de reproducción sincronizada Deshacer Letras no encontradas Temporizador de apagado Al finalizar la canción 1 minuto %d de minutos %d minutos No hay un stream disponible No hay conexión a internet Tiempo de espera agotado Error desconocido Me gusta Marcar todo como me gusta Quitar me gusta Eliminar todos los me gusta Activar aleatorio Desactivar aleatorio Repetición desactivada Repetir canción actual Repetir cola Todas las canciones Canciones buscadas Reproductor de música Ajustes Apariencia Tema Habilitar tema dinámico Tema oscuro Activado Desactivado Tema del sistema Negro puro Personalizar pestañas de navegación Reproductor Alineación de texto del reproductor Posición de la letra de la canción Lateralmente Izquierda Centro Derecha Estilo de la barra del reproductor Predeterminado Ondulado Otros Pestaña abierta predeterminada Tamaño de la celda de la cuadrícula Pequeño Grande Contenido Cuenta Sesión no iniciada Idioma de contenido predeterminado País de contenido predeterminado Predeterminado del sistema Activar «proxy» Tipo de «proxy» URL del «proxy» Reinicie para aplicar los cambios Reproductor y sonido Calidad de sonido Automática Alta Baja Cola Cola persistente Restaura tu última cola al iniciar la aplicación Omitir silencios Normalización del audio Saltar automáticamente a la siguiente canción cuando se produce un error Asegura una experiencia de reproducción continua Detener la música cuando se cierre la aplicación Ecualizador Almacenamiento Caché Caché de imágenes Caché de canciones Tamaño máximo de la caché Ilimitado Borrar todas las descargas Tamaño máximo de la caché de la imagen Borrar caché de imágenes Tamaño máximo del caché de canciones Borrar caché de canciones %s usado Privacidad Pausar historial de reproducciones Borrar historial de reproducciones ¿Confirma que quiere borrar todo el historial de reproducciones? Pausar historial de búsquedas Borrar historial de búsquedas ¿Confirma que quiere borrar todo el historial de búsquedas? Activar proveedor de letras LrcLib Activar proveedor de letras KuGou Ocultar contenido explícito Copias de seguridad y restauración Respaldar Restaurar Lista de reproducción importada Copia de seguridad creada con éxito No se ha podido crear la copia de seguridad Error al restaurar la copia de seguridad Integración con Discord Metrolist utiliza la biblioteca KizzyRPC para establecer el estado de su cuenta de Discord. Esto implica el uso de la conexión Discord Gateway, lo que podría considerarse una violación de las condiciones del servicio de Discord. Sin embargo, no se conocen casos de cuentas de usuario suspendidas por este motivo. Úselo bajo su propio riesgo.\n\nMetrolist solo extraerá su ficha; todo lo demás se almacena localmente. Descartar Opciones Vista previa Error al acceder a la cuenta Finalizar sesión Activar Rich Presence Acerca de Versión de la app Nueva versión disponible Modelos de traducción Borrar modelos de traducción Historial de reproducciones Sus listas de reproducción de YouTube Quitar todo de la biblioteca Historial de búsqueda Desactivar captura de pantalla Cargar automáticamente más canciones Cuando esta opción está activada, las capturas de pantalla y la vista de la aplicación en Recientes estará deshabilitada. Añade automáticamente más canciones cuando llegues al final de la cola, si es posible Otras versiones Añadir todo a la biblioteca Utilice el inicio de sesión para ver el contenido Esto puede influir en el contenido que ve y, por ejemplo, muestra álbumes exclusivos si ha iniciado sesión con una cuenta Premium Acceder ================================================ FILE: app/src/main/res/values-es-rUS/metrolist_strings.xml ================================================ Letras sincronizadas Inicio de sesión con token Presiona para mostrar el token Presiona de nuevo para copiar o editar General Proxy Cambiar pestaña predeterminada de la biblioteca Establecer selecciones rápidas Basado en canciones escuchadas últimamente Idioma de la aplicación Agregar automáticamente más canciones similares cuando la cola se termine Todo el tiempo Últimas 24 horas %d%% No se pudo abrir la configuración de la aplicación Longitud de mi lista de favoritos Información Listas de reproducción automáticas Mostrar \"Mis me gusta\" Mostrar \"Música descargada\" Mostrar \"Top de reproducción\" Mostrar la lista de reproducción “En caché” Habilitar contenido similar Importar una lista \"m3u\" Importar una lista \"csv\" Nota: Añadir canciones locales a listas de reproducción sincronizadas/remotas no está soportado, cualquier otra combinación es valida Notas de actualizaciones Semana pasada Mes pasado Descripción Vistas Me gustas Duración del historial Local Remoto Lista de éxitos Atrás Portada de álbum Top de vídeos músicales Semanas Años Continuo Tus \"me gusta\" Descargados Guardado en la caché Sincronizar lista de reproducción Sincronización desactivada Nota: Esto permite sincronizarse con YouTube Music. NO se puede cambiar después. Generando imagen Por favor espera Cancelar Mis favoritos %d vez %d veces %d veces Compartir letras Compartir como texto Compartir como imagen Se alcanzó el límite en la selección Compartir lo seleccionado Personalizar colores Color del texto Color secundario del texto Color del fondo Eliminar de la caché Copiar enlace Seleccionar todos Dar me gusta a todo Dar no me gusta a todo Fecha actualizada Letras Actualmente en la lista de reproducción: Contenido similar Estilo del reproductor de fondo Seguir tema Gradiente Desenfoque Colores de los botones del reproductor Por defecto Activar deslizar para pasar canción Desliza la canción hacia la izquierda para agregarla a la cola, o hacia la derecha para reproducirla a continuación Cambiar letra al presionar Descargar automáticamente al dar \"me gusta\" Automáticamente se descargarán las canciones cuando le des me gusta ¿Estás seguro de eliminar todas las canciones de la caché? ¿Estás seguro de eliminar todas las canciones descargadas? No haz iniciado sesión en YouTube Abrir links soportados No me gusta 1 segundo %d segundos %d segundos Tendencia Link copiado al portapapeles Este es un método de inicio de sesión AVANZADO. Como una alternativa de la página web, puedes entrar directamente o actualizar tu token de sesión aquí. Por ejemplo, esto puede acelerar tu inicio de sesión en múltiples dispositivos. Ten en cuenta que cualquier formato invalido del token no es aceptado Meses Año pasado Delgado Barra de navegación inferior delgada Reproduciendo Ahora Cerrar Ocultar carátula del reproductor Reemplaza la carátula del álbum por el logo de la app en el reproductor +%1$d segundos adelante -%1$d segundos hacia atrás Búsqueda progresiva Si se activa, incrementará 5 segundos en cada salto de búsqueda Nuevo diseño del reproductor Nuevo diseño del mini reproductor Romanizar letras japonesas Romanizar letras coreanas Auto-sincronizar con cuenta Más contenido Sensibilidad de deslizamiento del mini reproductor ¿Estás seguro de querer limpiar todas las imágenes en caché? Desactivar Suscribir Suscrito Deshabilitar cargar más al repetir todo No precargar más canciones ni contenido similar cuando repetir todo está activado %1$d%% Iniciando radio Interfaz Privacidad y seguridad Reproductor y contenido Almacenamiento y datos Sistema e información Editar carátula de la lista de reproducción Nota: Tu cuenta debe estar vinculada a un número de teléfono y verificada en YouTube Music para modificar la carátula de la lista de reproducción. Luego de seleccionar una imagen, deberás esperar un momento para que la nueva carátula aparezca en tu lista de reproducción. Configurar proxy Nombre de usuario del proxy Contraseña del proxy Habilitar autenticación Cirílico Romanización Romanización de letras Romanizar letras en ruso Romanizar letras en ucraniano Romanizar letras en bielorruso Romanizar letras en kirguís Romanizar letras en serbio Romanizar letras en búlgaro EXPERIMENTAL: Detectar idioma línea por línea El idioma cirílico será detectado línea por línea en lugar de toda la canción. ¿Estás seguro? Esta función es experimental y propensa a fallos.\n\nPor defecto, el idioma es determinado a partir de la canción completa, pero al activar esta opción, será determinado línea por línea. Esto permitirá que las canciones multilingüe funcionen, PERO el idioma podría no ser siempre el correcto (por ejemplo, si hay alguna letra en ucraniano que no contiene caracteres específico de este idioma, podría ser romanizada al ruso).\n\nSi no tienes inconvenientes, es recomendable que dejes esta opción desactivada. Romanizar pista actual Subidas Desliza la canción para eliminarla de la playlist Mostrar listas de reproducción \"Subidas\" Elija de la biblioteca Eliminar imagen personalizada Usar detalles en lugar de estado Subido Descargar todas las canciones para reproducirlas sin conexión Remover todas las canciones descargadas de esta lista de reproducción La descarga está en progreso Compartir esta lista de reproducción con otros Eliminar esta lista de reproducción de forma permanente Sincronizar la lista de reproducción con YouTube Music Color primario Color terciario Habilitar efecto de brillo de la letra Añadir animación de brillo y efecto de rebote a la letra activa Habilitar Better Lyrics Usar el proveedor Better Lyrics para letras sincronizadas palabra a palabra Sincronizar de nuevo Aleatorizar lista de reproducción/álbum primero Al aleatorizar, reproducir primero todas las canciones de la lista de reproducción/álbum original, luego el contenido similar Mostrar tarjeta de Recap Mostrar resaltado el título de la canción en lugar de los nombres de los artistas Romanizar letras chinas Actualizador Buscar actualizaciones automáticamente Habilitar las notificaciones de actualización Actualización disponible Actualizaciones de la app Notificaciones de nuevas versiones Habilitar descarga de audio Usar la ruta de descarga de audio para la reproducción. Desactivar esto puede aumentar el consumo de batería, pero podría ser útil si estás experimentando problemas con la reproducción o posprocesado de audio Google Cast Habilitar la transmisión de audio a Chromecast y otros dispositivos compatibles con Cast Romanizar letras en macedonio Integraciones Nombre de usuario Contraseña Integración con Last.fm Habilitar scrobbling Enviar lo que estoy escuchando Enviar Me gusta/No me gusta Marcar como Me gusta/No me gusta las canciones en Last.fm cuando son marcadas como Me gusta/No me gusta en Metrolist Iniciando sesión… Ondulado Habilitar letras SimpMusic Usar el proveedor SimpMusic para letras sincronizadas Recordar aleatorio y repetir Recordar el estado de los modos aleatorio y repetir al reiniciar la app Pausar la música al silenciar la multimedia Compensación de letras Configuración de scrobbling Hacer scrobble a canciones más largas que Porcentaje de retraso de scrobble Minutos de retraso de scrobble Ocultar canciones de video Ver la información de la canción Cambiar el título o artista Crear una estación basada en este elemento Añadir al inicio de la cola Añadir al final de la cola Guardar en tu biblioteca Hacer disponible para reproducir sin conexión Mostrar menos Mostrar más Acerca Página del artista Mostrar descripción del artista Mostrar oyentes mensuales Mostrar cantidad de suscriptores Avanzar rápidamente por las partes silenciosas de las canciones Saltar silencio de forma instantánea Saltar hacia adelante durante los momentos silenciosos en lugar de acelerar la reproducción Aleatorio persistente Mantener el modo aleatorio activado al iniciar nuevas canciones o listas de reproducción Añadir a una de tus listas de reproducción Obtener los metadatos más recientes de YouTube Music Compartir un enlace a este elemento Remover este elemento permanentemente Cambiar la velocidad y el tono de la canción Ajustar el ecualizador de audio Habilitar icono dinámico Mini reproductor Mini reproductor completamente negro ¡Espera! Elegiste un límite de tamaño de caché menor que el usado actualmente por la app (%1$s). Si continúas, la app podría remover algunos %2$s de la caché para ajustarse al nuevo límite. ¿Proceder de todas formas? Continuar Estilo de animación palabra a palabra Ninguno Difuminar Brillar Deslizar Karaoke Apple Music Tamaño del texto de la letra Interlineado de la letra Carátula del álbum para %s Ya escuchaste álbumes únicos Tu álbum favorito es Tu lista de reproducción personal está lista Tus 5 álbumes favoritos Escuchaste este álbum durante %d minutos %d minutos Sin datos Tus artistas favoritos del año %d minutos Tus canciones favoritas del año Carátula del álbum Tu artista favorito del año es Imagen del artista favorito Los escuchaste durante %d minutos Tu canción más reproducida es Escuchaste durante %d minutos Escuchaste a artistas únicos Escuchaste canciones únicas METROLIST es hora de ver lo que estuviste escuchando ¡vamos! Logo de Metrolist 2025 ¡TU RECAP ESTÁ LISTO! Hora de ver lo que te gustó este año. Gracias por escuchar Agradecimiento especial a MO Agamy por crear Metrolist Cerrar Recap Tu Recap %s Crear lista de reproducción Lista de reproducción guardada %d perfil %d de perfiles %d perfiles Ecualizador No hay perfiles de ecualizador Importar perfil Ecualizador del sistema Desactivado %d bandas %d de bandas %d bandas Eliminar perfil ¿Estás seguro de que quieres eliminar %1$s? Esta acción no se puede deshacer. No se pudo leer el archivo No se pudo abrir el archivo: %1$s Error al importar Transmitiendo a %s Escuchando Metrolist Abrir No se pudo crear la imagen: %s Se copió el título Se copió el artista Error al reproducir No se pudo analizar la URL del proxy. Carátula del álbum Ninguna canción en reproducción Toca para abrir Metrolist Anterior Reproducir/pausar Siguiente Me Gusta Widget de reproductor de música con controles de reproducción Widget de música circular con controles de reproducción y me gusta Progreso %s%% Falló la reproducción Recortar carátula del álbum Recortar las miniaturas de los videos para forzar una relación de aspecto cuadrada Error No se pudo aplicar el perfil de ecualización: %1$s Mantener la pantalla encendida cuando el reproductor esté expandido Escuchar juntos URL del servidor Nombre de usuario Conectado Reconectando… Desconectado Conectando… Error de conexión Crear sala Crea una sala y comparte el código con tus amigos Unirse a la sala Código de la sala Eres el anfitrión Eres un invitado Solicitudes para unirse Ver registros Depurar conexión y mensajes Registros de conexión Aún no hay registros Escucha música con tus amigos en tiempo real. Crea una sala para ser el anfitrión o únete a una sala existente con un código. Nota: Es posible que se te desconecte si creas una sala mientras no hay música reproduciéndose y luego cambias a otra aplicación. Escuchar juntos no está configurado. Por favor, configura la URL del servidor en Ajustes → Integraciones → Escuchar juntos. %1$s solicitó %2$s ¡Sugerencia enviada al anfitrión! %1$s quiere unirse a la sala Escuchar juntos Notificaciones para eventos de Escuchar juntos Sala creada: %s No puedes editar el nombre de usuario mientras estás en una sala Esperando aprobación del anfitrión Código de sala inválido Solicitud para unirse rechazada Unirse a una sala existente Código de la sala Salir de la sala Unirse Crear Uniéndose a la sala %s… Creando sala… Conectar Desconectar Crear Unirse Aprobar Rechazar Limpiar Copiar Copiado al portapapeles No configurado Alojando la sala En la sala Solicitudes pendientes Sugerencias pendientes Sugerir al anfitrión Expulsar Anfitrión Usuarios conectados Introducir nombre de usuario El nombre de usuario es obligatorio. Volver a sincronizar Habilitar Sólido Previene el duplicado de canciones en la cola de reproducción Al añadir una pista a la cola, elimínela de su posición anterior si ya está presente Resume al conectar Bluetooth Transición suave Transición suave entre canciones Duración del desvanecido Desactivar para álbumes sin pausas No desvanecer si el álbum no tiene pausas Funciones Beta Transición suave es una nueva función y puede tener errores. Si experimentas algún problema, por favor, infórmanos.\n\nEsta función desactiva la descarga de audio debido a limitaciones técnicas. Romanizar letras Hindi Romanizar letras Punjabi Deshabilitado porque el desvanecido esta activo Mostrar letras romanizadas como principales Ocultar Youtube Shorts No se está reproduciendo ninguna canción Toca para abrir Metrolist Reproductor de Música Disco giratorio En compañia Elije el servidor Servidor personalizado Usar servidor personalizado Silenciar Activar sonido Aprobar automáticamente las solicitudes de ingreso Aprobar automáticamente las solicitudes de ingreso en lugar de revisarlas manualmente Sincronizar volumen del anfitrión Los invitados siguen el nivel de volumen del anfitrión Mover Escuchar Juntos a la barra superior Mostrar Escuchar Juntos en la barra superior en lugar de la barra de navegación Ingresa el código de la sala Configurar servidor, nombre de usuario y más Copiar código Remover a esta persona de la sesión Bloquear permanentemente Bloquear y ocultar las solicitudes de ingreso para esta persona Transferir propiedad Convertir esta persona en el anfitrión de la sala Gestionar usuario Usuarios bloqueados %d usuario(s) bloqueado(s) No hay usuarios bloqueados Desbloquear Usuario bloqueado por el anfitrión Traducción de letras con IA Traduciendo letras... Letras traducidas Proovedor URL base Clave API Modelo Modo de traducción Idioma de destino Credenciales de la API Traducción Traducir el significado al idioma de destino Transcripción Convertir la pronunciación al alfabeto de destino Clave API requerida Clave API es requerida Sin letras para traducir Las letras están vacías Es requerido un idioma de destino Resultado de traducción inesperado Ocurrió un error desconocido Traducción fallida Obtener claves API Visita openrouter.ai para modelos gratuitos y de pago Visita platform.openai.com/api-keys Visita console.anthropic.com/settings/keys Visita aistudio.google.com/apikey Visita perplexity.ai/settings/api Visita console.x.ai Visita deepl.com/pro-api para modelos gratuitos y de pago Formalidad Por defecto Más formal Menos formal La aplicación se cerró Se ha producido un error inesperado. Por favor, comparta el informe de errores para ayudarnos a solucionar el problema. Compartir Registros Compartir informe de errores Informe de errores de Metrolist Cerrar No hay registro de fallos disponible Dinámico Carmesí Rosa Púrpura Púrpura Intenso Indigo Azul Azul Cielo Cian Verde Azulado Verde Verde Claro Verde Lima Amarillo Ámbar Naranja Naranja Intenso Café Gris Azul Grisáceo Volver Modo Negro Puro Modo claro Modo oscuro Sistema Paleta %1$s Reproducir todo Habilitar alta tasa de refresco Obliga a la pantalla a funcionar con la frecuencia de actualización más alta compatible (por ejemplo, 120 Hz) Identificar Canción Toca para identificar Escuchando… Procesando… No se ha encontrado ninguna coincidencia Error de reconocimiento Intenta de nuevo Historial de reconocimientos Limpiar el historial de reconocimientos ¿Estás seguro de que deseas borrar todo el historial de reconocimiento? Eliminar del historial Volver a escuchar Escuchar en Metrolist Asignar columnas CSV La primera fila es el encabezado Columna del nombre del artista Columna del título de la canción Columna URL de YouTube (opcional) Continuar Importando CSV Convertidos Recientemente Columna %d Estado En línea Inactivo No Molestar Botones Botón 1 Botón 2 ¡Inicio de sesión exitoso! Esta función utiliza la biblioteca KizzyRPC para conectarse a la puerta de enlace de Discord y configurar tu estado de presencia enriquecida. Aunque no se conoce ningún caso de suspensión de cuentas por un uso similar, este método no está oficialmente respaldado por Discord y puede considerarse una infracción de los Términos de servicio. Tu token se extrae localmente y nunca se envía a servidores de terceros. Procede bajo tu propia responsabilidad. Tipo de actividad Reproduciendo Escuchando Viendo Compitiendo Variables: {nombre_de_la_canción}, {nombre_del_artista}, {nombre_del_álbum} Previsualizar Presencia Presencia Inicia sesión con Discord para compartir lo que estás escuchando Reproduciendo en Metrolist Viendo en Metrolist Compitiendo en Metrolist Nombre de la actividad Nombre personalizado para la actividad (déjelo vacío para usar el nombre predeterminado) Modo avanzado Mostrar opciones de personalización adicionales para la Presencia Enriquecida Densidad de la pantalla Reiniciar Reinicio requerido El cambio en la densidad de la pantalla tendrá efecto después de reiniciar la aplicación. ¿Quieres reiniciarla ahora? Encontrado en ajustes > Contenido reproducciones Velocidad de reproducción Aleatorizar el orden de la pantalla de inicio Reordenar aleatoriamente las secciones de la pantalla de inicio según prioridad Porque escuchas a %1$s Similar a %1$s Basado en %1$s Para fans de %1$s De la comunidad Sonidos como %1$s Borrando… ================================================ FILE: app/src/main/res/values-es-rUS/strings.xml ================================================ Estado de ánimo y géneros Cuenta Selecciones rápidas Escucha canciones para generar tus selecciones rápidas Favoritos olvidados Sigue escuchando Tus listas de reproducción de YouTube Similar a Nuevos álbumes de lanzamiento ID Multimedia Inicio Canciones Artistas Álbumes Listas de reproducción %d seleccionada %d de seleccionadas %d seleccionadas Historial Estadísticas Hoy Ayer Esta semana La semana pasada Canciones más reproducidas Artistas más reproducidos Álbumes más reproducidos Buscar Buscar música en YouTube… Buscar biblioteca… Biblioteca Favoritos Descargado Todo Canciones Vídeos Álbumes Artistas Listas de reproducción Listas de reproducción de la comunidad Listas de reproducción destacadas Marcado como favorito No se encontraron resultados Las canciones de la biblioteca aparecerán aquí La biblioteca de artistas aparecerán aquí Los álbumes de la biblioteca aparecerán aquí Las listas de reproducción aparecerán aquí Desde tu biblioteca Otras versiones Canciones favoritas Canciones descargadas La lista de reproducción está vacía ¿Realmente quieres eliminar todas las canciones de la lista de reproducción \"%s\" del almacenamiento de canciones descargadas? ¿Realmente quieres eliminar la lista de reproducción \"%s\"? Volver a intentar Radio Aleatorio Reiniciar Detalles Editar Nombre Iniciar radio Reproducir Reproducir siguiente Añadir a la cola Añadir a la biblioteca Añadir todo a la biblioteca Quitar de la biblioteca Eliminar todo de la biblioteca Descargar Descargando Eliminar descarga Importar lista de reproducción Agregar a la lista de reproducción Ver artista Ver álbum Recargar Compartir Borrar Eliminar del historial Eliminar de la lista de reproducción Quitar de la cola Buscar en línea Sincronizar Avanzado Tempo y tono Fecha añadida Artista Año Número de canciones Duración Intensidad Volumen Tamaño del archivo Desconocido Copiado al portapapeles Editar letra Tiempo de reproducción Orden personalizado Tipo de MIME Códecs Tasa de bits Frecuencia de muestreo Artista/s de la canción Nombre para la lista de reproducción El título de la canción no puede estar vacío. Crear lista de reproducción Debes poner un nombre a la lista de reproducción. No puede estar vacío el artista de la canción. Título de la canción Guardar Escoger lista de reproducción Editar lista de reproducción Buscar letra Editar canción Editar artista Nombre del artista Debe haber un nombre para el artista. Duplicados Saltar duplicados Agregar de todos modos Esta canción ya está en tu lista de reproducción %d canciones ya están en tu lista de reproducción %d canción %d canciones %d canciones %d artista %d de artistas %d artistas %d álbum %d de álbumes %d álbumes %d lista de reproducción %d de listas de reproducción %d listas de reproducción %d semana %d de semanas %d semanas %d mes %d meses %d meses %d año %d años %d años Lista de reproducción importada Se ha eliminado «%s» de la lista de reproducción Lista de reproducción sincronizada Deshacer No se han encontrado letras Fin de la canción 1 minuto %d minutos %d minutos No hay transmisión disponible Sin conexión a la red Tiempo de espera Error desconocido Me gusta Eliminar «Me gusta» Eliminar todos los «Me gusta» Mezclar activado Mezclar apagado Repetir la canción actual Repetir cola Todas las canciones Canciones buscadas Reproductor de música Configuración Tema Activar tema dinámico Tema oscuro Seguir el sistema Negro puro Personalizar pestañas de navegación Reproductor Alineación del texto del reproductor Posición del texto de la letra Izquierda Centro Derecha Estilo del control deslizante del reproductor Varios Pestaña predeterminada abierta Pequeña Grande Contenido Cerrar sesión Iniciar sesión Temporizador de apagado Modo de repetición desactivado Apariencia Encendido Apagado con bordes Predeterminado Tamaño de celda de la cuadrícula Inicio No ha iniciado sesión Error al iniciar sesión Idioma predeterminado del contenido País predeterminado del contenido Predeterminado del sistema Activar proxy Tipo de proxy URL de proxy Reiniciar para que surta efecto Reproductor y audio Calidad de audio Automático Alto Bajo Cola Cola persistente Restaurar la última cola al iniciar la aplicación Cargar automáticamente más canciones Añadir automáticamente más canciones cuando se llegue al final de la cola, si es posible Saltar silencio Normalización del audio Saltar automáticamente a la siguiente canción cuando se produce un error Asegúrate de que la reproducción no se interrumpa Detener la música al finalizar la tarea Ecualizador Almacenamiento Caché Caché de imágenes Caché de canciones Tamaño máximo de la caché Ilimitado Borrar todas las descargas Tamaño máximo de la caché de imágenes Borrar caché de imágenes Tamaño máximo de la caché de canciones Borrar caché de canciones %s utilizado Privacidad Escuchar historial Pausar historial de escucha Borrar historial de escucha ¿Estás seguro de que quieres borrar todo el historial de escucha? Historial de búsqueda Pausar el historial de búsqueda Borrar historial de búsqueda ¿Estás seguro de que quieres borrar todo el historial de búsqueda? Utiliza el inicio de sesión para navegar por el contenido Esto puede influir en el contenido que ves y, por ejemplo, muestra álbumes exclusivos para usuarios Premium si has iniciado sesión con una cuenta Premium Desactivar captura de pantalla Cuando esta opción está activada, las capturas de pantalla y la vista de la aplicación en Recientes están desactivadas. Activitar proveedor de letras LrcLib Activitar proveedor de letras KuGou Ocultar contenido explícito Copia de seguridad y restauración Copia de seguridad Restaurar Lista de reproducción importada Copia de seguridad creada correctamente No se pudo crear la copia de seguridad No se pudo restaurar la copia de seguridad Integración con Discord Metrolist utiliza la biblioteca KizzyRPC para configurar el estado de tu cuenta de Discord. Esto implica el uso de la conexión Discord Gateway, lo que puede considerarse una violación de los Términos de servicio de Discord. Sin embargo, no se conocen casos de cuentas de usuario suspendidas por este motivo. Úsalo bajo tu propia responsabilidad.\n\nMetrolist solo extraerá tu token, y todo lo demás se almacena localmente. Desestimar Opciones Vista previa Activar Presencia Enriquecida Sobre Versión de la aplicación Nueva versión disponible Modelos de traducción Modelos de traducción claros Me gustan todas Ondulado ================================================ FILE: app/src/main/res/values-et/metrolist_strings.xml ================================================ %d kord %d korda 1 sekund %d sekundit Palun oota Katkesta Jaga laulusõnu Jaga tekstina Jaga pildina Kopeeri link Vali kõik Märgi kõik meeldivaks Märgi kõik mittemeeldivaks Uuendamise kuupäev Link on kopeeritud lõikelauale Laulusõnad Juba on esitusloendis: Albumi kaanepilt Kohalik Serveris Edetabelid Tagasi Populaarsed muusikavideod Populaarsust koguvad lood Nädalaid Kuid Aastaid Järjepidev Meeldivaks märgitud Allalaaditud Minu lemmikud Puhverdatud Sünkroniseeri esitusloend Sünkroniseerimine pole kasutusel Märkus: see võimaldab sünkroonimist YouTube Musicuga. Sa EI SAA seda seadistust hiljem muuta. Loon pilti Valiku ülempiir Jaga valitut Kohenda värve Teksti värv Teisane tekstivärv Taustavärv Kustuta puhverdatud andmetest Sarnane sisu Meediaesitaja tausta stiil Järgi kujundust Hägustatud taust Meediaesitaja uus kujundus Gradient Meediaesitaja nuppude värvid Kasuta loo vahetamisel viipamist Vasakule viibates saa lisada loo esitusjärjekorda ning paremale viibates esitatakse teda järgmisena Vaikimisi Vaheta laulusõnu klõpsimisega Keri laulusõnu automaatselt Latiniseeri jaapanikeelseid laulusõnu Latiniseeri koreakeelseid laulusõnu Näita esitusloendit „Meeldib“ Näita esitusloendit „Allalaaditud“ Näita esitusloendit „Populaarne“ Näita esitusloendit „Puhverdatud“ Logi sisse tunnusloaga Tunnusloaga vaatamiseks klõpsi Klõpsi muutmiseks või kopeerimiseks %d%% Impordi m3u vormingus esitusloendeid Impordi csv vormingus esitusloendeid Läbi aegade Viimase 24 tunni jooksul Viimase nädala jooksul Viimase kuu jooksul Viimase aasta jooksul Rakenduse keel Automaatsed esitusloendid Põhineb viimasel kuulatud lool Rakenduse seadistuste avamine ei õnnestunud Muudatuste logi Ajaloo kestus Teave Kirjeldus Vaatamisi Meeldimisi Mittemeeldimisi Telli Tellitud Minu parimate lugude loendi pikkus Käivitan raadiot Praegu esitamisel Sulge +%1$d sekundit edasi -%1$d sekundit tagasi Meediaesitaja uus kompaktne kujundus Üleslaaditud Üleslaaditud Üldised Näita esitusloendit „Üleslaaditud“ Täiendav sisu Muuda esitusloendi kaanepilti Vali meediakogust Eemalda sinu valitud pilt Lisa kiirvalikud Seadista proksiserverit Puhverserveri kasutajanimi Puhverserveri salasõna Kasutajaliides Privaatsus ja turvalisus Uuendaja Kontrolli uuendusi automaatselt Lülita uuenduste teavitused sisse Uus versioon on saadaval Rakenduse uuendused Lõimingud Kasutajanimi Salasõna Last.fm-i lõiming Lülita kraasimine sisse Saada hetkel esitatava loo andmed Saada meeldimised/mittemeeldimised Kraasimise seadistused Kuva artisti kirjeldust Kuva tellijate arvu Kuva igakuine kuulajate arv Kuva vähem Kuva rohkem Artisti leht Teave Laadi alla kõik lood võrguühenduseta taasesituseks Eemalda kõik alla laetud lood sellest esitlusloendist Allalaadimine on pooleli Jaga esitlusloendit teistega Kustuta see esitlusloend jäädavalt Sünkroniseeri esitlusloend Youtube Music\'ga ================================================ FILE: app/src/main/res/values-et/strings.xml ================================================ Esitusloendid Ajalugu Statistika Meeleolu ja žanrid Kasutajakonto Kiirvalikute loomiseks kuula lugusid Unustatud lemmikud Uued avaldatud albumid Täna Eile Kõige rohkem esitatud lood Kõige rohkem esitatud esinejad Otsi YouTube Musicust… Otsi muusikakogust… Muusikakogu Meeldivaks märgitud Allalaaditud Kõik Lood Videod Albumid Esitajaid Esitusloendid Kogukonna esitusloendid Esiletõstetud esitusloendid Tulemusi ei leidu Muusikakogu lood saavad olema kuvatud siin Muusikakogu esitajad saavad olema kuvatud siin Muusikakogu albumid saavad olema kuvatud siin Muusikakogu esitusloendid saavad olema kuvatud siin Kas sa kindlasti soovid kõik „%s“ esitusloendi lood eemaldada allalaaditud lugude andmekogust? Kas sa kindlasti soovid kustutada „%s“ esitusloendi? Proovi uuesti Raadio Sega lood Muuda Lisa kõik muusikakogusse Eemalda muusikakogust Eemalda kõik muusikakogust Laadi alla Laadime alla Eemalda allalaaditu Impordi esitusloend Vaata esitajat Jaga Kustuta Eemalda ajaloost Eemalda esitusloendist Eemalda esitusjärjekorrast Otsi veebist Sünkroniseeri Lisavalikud Avaleht Albumid Lood Esitajaid %d valitud %d valitud Kiirvalikud Sinu YouTube\'i esitusloendid Jätka kuulamist Sarnane nagu Sel nädalal Eelmisel nädalal Kõige rohkem esitatud albumid Otsi Lisatud järjehoidjaks Sinu muusikakogust Muud versioonid Meeldivaks märgitud lood Allalaaditud lood Esitusloend on tühi Pane raadio tööle Vaata albumit Lähtesta Üksikasjad Esita Esita järgmisena Laadi uuesti Lisa esitusjärjekorda Lisa muusikakogusse Lisa esitusloendisse Tempo and helikõrgus Pikkuse alusel Esitusaja järgi Kohandatud järjekorras Meedia tunnus Meedia tüüp Helitugevus Loo pealkiri Loo esitaja Loo pealkiri ei saa olla tühi. Loo esitusloend Esitusloendi nimi Muuda esitajat Esitaja nimi Esitaja nimi ei saa olla tühi. Topeltlood Lisa ikkagi %d lugu %d lugu %d esitaja %d esitajat %d nädal %d nädalat %d kuu %d kuud %d aasta %d aastat Esitusloend on sünkroniseeritud Pööra tegevus tagasi Laulusõnu ei leidu Loo lõpp 1 minut %d minutit Meediavoogu pole saadaval Eemalda kõik meeldivaks märkimised Lugude segamine on kasutusel Lugude segamine pole kasutusel Kordus pole kasutusel Korda seda lugu Korda esitusjärjekorda Kõik lood Otsitud lood Muusikamängija Seadistused Välimus Kujundus Kasuta dünaamilist kujundust Tume kujundus Õige must kujundus Kohenda vahekaarte Muusikamängija Muusikamängija teksti joondumine Laulusõnade asukoht Küljel Vasakul Keskel Paremal Muusikamängija liugurnupu stiil Vaikimisi Väänlev Varia Vaikimisi avatav vahekaart Vaikimisi eelistatav riik sisu kuvamisel Vaikimisi eelistatav keel sisu kuvamisel Süsteemi vaikeseadistused Kasuta proksiserverit Proksiserveri tüüp Proksiserveri aadress Muudatuse jõustamiseks käivita rakendus uuesti Meediamängija ja heli Laadi automaatselt täiendavaid lugusid Failid ja meedia Vahemälu Piltide vahemälu Lugude vahemälu Vahemälu suuruse ülempiir Piiramatu Eemalda kõik allalaaditud lood Piltide vahemälu suuruse ülempiir Eemalda vahemälust pildid Lugude vahemälu suuruse ülempiir Eemalda vahemälust lood %s kasutusel Privaatsus Kuulamiste ajalugu Peata kuulamiste ajaloo salvestamine Kustuta kuulamiste ajalugu Kas sa oled kindel, et soovid kustutada kogu kuulamiste ajaloo? Otsingute ajalugu Peata otsingute ajaloo salvestamine Kustuta otsingute ajalugu Kas sa oled kindel, et soovid kustutada kogu otsingute ajaloo? Keela ekraanitõmmised Kui see eelistus on kasutusel, siis ekraanitõmmiste tegemine ja rakenduse vaade Viimaste rakenduste all on keelatud. Kasuta laulusõnade kuvamiseks teenusepakkujat LrcLib Loobu Eelvaade Näita oma tegevust Metrolist rakenduses Discordi olekuteatena Lisamise kuupäev Nime alusel Esitaja järgi Aasta järgi Lugude arvu alusel Koodekid Diskreetimissagedus Faili suurus Muuda laulusõnu Bitikiirus Otsi laulusõnu Teadmata Valjus Kopeeritud lõikelauale Muuda lugu Jäta topeltlood vahele Loo esitaja ei saa olla tühi. Aegumine Salvesta Esitusloendi nimi ei saa olla tühi. Vali esitusloend Muuda esitusloendit See lugu on sinu esitusloendis juba olemas Tundmatu viga %d lugu on sinu esitusloendis juba olemas Unetaimer Eemaldasime „%s“ esitusloendist %d album %d albumit %d esitusloend %d esitusloendit Esitusloend on imporditud Võrguühendus puudub Kõik meeldivad Meeldib Eemalda meeldivaks märkimine Järgi süsteemi kujundust Kasutusel Pole kasutusel Madal Helikvaliteet Automaatne Kõrge Püsiv esitusjärjekotrd Rakenduse uuesti käivitamisel taasta viimatikasutatud esitusjärjekord Esitusjärjekord Esitusjärjekorra lõppedes, kui vähegi võimalik, siis laadi automaatselt täiendavaid lugusid Vea puhul hüppa automaatselt järgmise loo juurde Jäta vaikus vahele Tegumist eemaldamisel peata muusika esitamine Heli normaliseerimine Taga jätkuv taasesitus Ekvalaiser Rakenduse teave Tõlkemudelid Rakenduse versioon Uus versioon on saadaval Eemalda tõlkemudelid Väike Ruudustiku elemendi suurus Suur Sisu Kasutajanimi Pole sisselogitud Kasuta laulusõnade kuvamiseks teenusepakkujat KuGou Peida ebasobilik sisu Varukoopia ja taastamine Varunda Taasta Imporditud esitusloend Varukoopia tegemine õnnestus Varukoopia tegemine ei õnnestunud Varukoopiast taastamine ei õnnestunud Lõiming Discordiga Metrolist kasutab Discordi kasutajakonto oleku kuvamiseks KizzyRPC teeki. See eeldab Discord Gateway ühenduse kasutamist ja seda loetakse Discordi kasutustingimuste rikkumiseks. Aga pole teada ühtegi juhust, kus kasutajakonto oleks sel põhjusel keelatud. Kasuta seda teenust omal vastutsusel.\n\nMetrolist kasutab ainult sinu tunnusluba, kõike muud hoitakse kohalikus seadmes. Valikud Logi välja Sisselogimine ei õnnestunud Sisu sirvimiseks logi sisse See võib mõjutada mis sisu sa näed, sealhulgas kui kasutad YouTube Premium-kontot, siis YouTube Premiumi jaoks mõeldud albumeid Logi sisse ================================================ FILE: app/src/main/res/values-eu/metrolist_strings.xml ================================================ Arrakasta Zerrendak Atzera Albumaren azala Musika bideo onenak Joera Asteak Hilabeteak Urteak Etengabe Gustoko Jaiskia Nire onenak Cachean gordeta Zerrenda sinkronizatu Sinkronizazioa itzalita Oharra: Honek YouTube Music-ekin sinkronizatzea ahalbidetzen du. Ondoren ezin izango da aldatu. Irudia sortzen Itxaron, mesedez Utzi Lokala Urrutiko Historia Letrak partekatu Testu moduan partekatu Irudi moduan partekatu Gehienezko hautaketa Hautatuak partekatu Koloreak aldatu Testu kolorea Bigarren testu kolorea Atzealde kolorea \"Cache\"tik ezabatu Link-a kopiatu Hautatu dena Dena gustoko Eguneratze data Link-a arbelera kopiatuta Irratia pizten Orain jotzen Letrak Itxi Miniatura ezkutatu Album irudia aplikazio logoarekin aldatu Erreprodukzio listan: Aldi %d %d aldi %1$d segundu aurrera -%1$d segundu atzera Bilaketa progresiboa Gaituta badago, bilaketa-jauzi bakoitzean 5 segundo extra gehitu Antzeko edukia Ez gustoko denak Erreproduzitzaile atzeko estiloa Jarraitu gaia Gradientea Erreproduzitzaile diseinu berria Mini-erreproduzitzaile diseinu berria Lauso-efektua Erreproduzitzaile botoi kolorea Lehenetsia Irristatu abestia aldatzeko gaitu Abestia ezkerretara irristatu ilara gehitzeko, edo eskuinera hurrengoan jotzeko Klik egitean letrak aldatu Letren mugimendu automatikoa Argala Nabigazio beheko barra argala Erreproduzio-zerrenda automatikoa \"Gustokoak\" zerrenda ikusi \"Deskargatuak\" zerrenda ikusk \"Top\" zerrenda ikusi \"Cache-an\" zerrenda ikusi Token-arekin saioa hasi Sakatu \"token\"-a ikusteko Sakatu berriro kopiatu edo editatzeko Hau SARRERA AURRERATUAREN metodoa da. Web-portalaren alternatiba gisa, hemen zuzenean sartu edo eguneratu dezakezu zure sarrera-tokena. Adibidez, honek hainbat gailutan sartzea azkartzen lagun dezake. Kontuan izan aplikazioak ez dituen token-formatu baliogabeak ez direla onartuko Kontuarekin sinkronizazio automatikoa Eduki gehiago Editatu erreprodukzio-zerrendaren azala Oharra: Zure kontua telefono-zenbaki batekin lotuta egon behar da eta YouTube Music-en egiaztatuta egon behar du erreprodukzio-zerrendaren azala aldatzeko. Irudi bat hautatu ondoren, mesedez itxaron une batez zure erreprodukzio-zerrendan azala agertu dadin. Liburutegitik hautatu Irudi pertzonalizatua kendu Orokorra Proxy Liburutegi lehenetsiaren chip-a aldatu Hautatze arinak ezarri Azken abestian oinarritua Aplikazio hizkuntza Proxy-a ezarri Proxy erabiltzaile izena Proxy pasahitza Autentikazioa gaitu Antzeko edukia gaitu Ilara amaitzerakoan, antzeko abestiak gehitu %%%d \"m3u\" zerrenda gehitu \"csv\" zerrenda gehitu Oharra: Tokiko abestiak sinkronizatutako / urrutiko erreprodukzio-zerrendetara gehitzea ez da onartzen. Beste edozein konbinazio baliogarria da Gustokoak automatikoki deskargatu Gustoko sakatzean abestiak automatikoki deskargatu Mini-erreproduzitzaile irrista-sentikortasuna %%%1$d Ziur zaude \"cache\"-an dauden abesti guztiak ezabatzea? Ziur zaude \"cache\"-an dauden irudi guztiak ezabatzea? Ziur zaude deskarga guztiak ezabatu nahi dituzula? Desgaitu YouTube-n saioa ez hasita Ireki onartutako estekak Aplikazio ezarpenak ezin dira ireki Bertsio oharrak Denbora guztia Azken 24 orduak Azken astea Azken hilabetea Azken urtea Nire Top zerrenda luzera Historia iraupena Informazioa Deskribapena Bistak Gustokoak Ez gustokoak Harpidetu Harpidetzatua Segundu %d %d segundu \"Errepikatu guztiak\" moduan gehiago kargatzea desgaitu Ez kargatu automatikoki abesti gehiago eta antzeko edukia ‘Errepikatu guztiak’ modua aktibatuta dagoenean Kirilikoa Erromanizazio Letren erromanizazioa Japoniar letrak erromanizatu Korear letrak erromanizatu Errusiar letrak erromanizatu Ukraniar letrak erromanizatu Kyrgizar letrak erromanizatu Serbiar letrak erromanizatu EXPERIMENTALA: Lerroz-lerro hizkuntza detektatu Bielorrusiar letrak erromanizatu Bulgariar letrak erromanizatu Kiriliko hizkuntza lerroz-lerro detektatuko da, abesti osoaren ordez. Ziur zaude? Ezaugarri esperimental hau ez da beti fidagarria.\n\nLehenetsita, hizkuntza abesti osoaren arabera zehazten da, baina aukera hau gaituta, lerroka zehaztuko da. Honek hizkuntza anitzeko abestien funtzionamendua ahalbidetuko du, BAINA hizkuntza ez da beti zuzena izango (adibidez, ukrainierazko lerro bat ez badu letra berezirik, errusieraz erromanizatua izan daiteke).\n\nArazoik ez baduzu, gomendagarria da aukera hau desgaituta uztea. Oraingo abestia erromanizatu Interfazea Pribatutasuna eta Segurtasuna Jotzailea eta Edukia Biltegia eta Datuak Sistema eta Honi buruz Offload desgaitu Erabili offload audio-bidea audioa erreproduzitzeko. Hau desgaitzeak energia-kontsumoa handitu dezake, baina erabilgarria izan daiteke audioa erreproduzitzean edo post-prozesamenduan arazoak badituzu ================================================ FILE: app/src/main/res/values-fa/metrolist_strings.xml ================================================ رنگ متن آیا مطمئن هستید که می خواهید همه موسیقی‌های دریافت شده را پاک کنید؟ لغو اشتراک گذاری به عنوان تصویر دریافت خودکار موسیقی‌ها زمانی که آنها را پسندیدید پیروی از پوسته سبک پس‌زمینه پخش‌کننده ۲۴ ساعت گذشته تغییر آهنگ با کشیدن را فعال کنید نمایش برترین لیست پخش‌ها درحال ساخت تصویر لطفا منتظر بمانید اشتراک گذاری متن آهنگ اشتراک به عنوان متن حداکثر محدودیت انتخاب به اشتراک گذاری انتخاب شده‌ها شخصی سازی رنگ‌ها رنگ متن دوم رنگ پس‌زمینه متن آهنگ همین الان در لیست پخش است: محتوای مشابه %d بار %d بار طیف رنگی رنگ دکمه‌های پخش کننده پیش فرض تار آهنگ را برای پخش بعدی به راست یا برای افزودن به صف‌پخش به چپ بکشید تغییر متن ترانه با کلیک لاغر برچسب‌های نوار پیمایش پایین را پنهان کنید لیست پخش خودکار نمایش لیست پخش پسند‌ شده‌ها نمایش لیست پخش دانلود شده‌ها فعال کردن محتوای مشابه هنگامی که به پایان صف رسید، آهنگ های مشابه بیشتری به صورت خودکار اضافه شود %d%% دریافت خودکار در صورت پسندیدن از پاک کردن همه آهنگ‌های کَش شده اطمینان دارید؟ به یوتیوب وارد نشده‌اید بازکردن پیوندهای پشتیبانی شده نمی‌توان تنظیمات برنامه را باز کرد یادداشت‌های انتشار درکل هفته گذشته ماه گذشته سال گذشته اطلاعات توضیحات پسندها %d ثانیه %d ثانیه نپسندیدن محلی بازگشت کاور آلبوم موزیک ویدیوهای برتر پرطرفدارها هفته‌ها ماه‌ها سالها دریافت شده‌ها برترین‌های من دستگاه راه دور مستمر پسندیده همگام‌سازی لیست پخش همگام‌سازی غیرفعال شد حدف از حافظه نهان رونوشت از پیوند انتخاب همه پسندیدن همه نپسندیدن همه تاریخ بروزرسانی شد پیوند کپی شد توجه: این مورد اجازه همگام‌سازی با یوتیوب موزیک را فراهم می کند. این مورد بعداً قابل تغییر نیست. برای رونوشت یا ویرایش دوباره ضربه بزنید عمومی پروکسی بر اساس آخرین آهنگ شنیده شده زبان برنامه ورود پیشرفته (توکن) ضربه برای نمایش توکن تنظیم انتخاب سریع بازدید‌ها چارت‌ها کش شده در حال پخش ثانیه به جلو ثانیه به عقب فعال‌سازی simpMusic Lyrics بستن درصورت فعال بودن، با هر بار پرش، ۵ ثانیه به‌صورت تدریجی اضافه می‌شود اسکرول خودکار متن‌ترانه برای حذف آهنگ از لیست پخش، آن را بکشید صفحه هنرمند نمایش توضیحات هنرمند طراحی جدید پخش‌کننده ­افزودن انیمیشن درخشش و افکت پرش به متن فعال ترانه برش تصویر البوم جایگزین کردن کاور آلبوم با لوگوی برنامه در پخش‌کننده نمایش شنوندگان ماهانه فعال‌سازی حالت نورانی متن ترانه نمایش بیشتر برش مربعی تصویر ویدیو همگام سازی این لیست پخش با یوتیوب موزیک حذف تمام اهنگ های دانلود شده از این لیست پخش رنگ اصلی استفاده از ارائه‌دهنده SimpMusic Lyrics برای متن ترانه همگام‌شده طراحی جدید مینی پخش‌کننده درحال شروع رادیو دانلود در جریان است باز‌همگام سازی دانلود تمام آهنگ‌ها برای پخش آفلاین اشتراک این لیست پخش با دیگران نمایش تعداد دنبال کنندگان حذف این لیست پخش برای همیشه موجی فعال‌سازی ترانه بهتر رنگ ثالث «استفاده از ارائه‌دهنده \"ترانه بهتر\" برای متن ترانه همگام‌شده کلمه‌-به‌-کلمه نمایش کمتر افزایش تدریجی زمان پرش درباره پنهان کردن تصویر پخش کننده ================================================ FILE: app/src/main/res/values-fa/strings.xml ================================================ خانه ترانه‌ها هنرمندها مجموعه‌ها فهرست‌پخش‌ها %d انتخاب‌شده %d انتخاب‌شده‌اند تاریخچه آمار حالت و سبک حساب انتخاب‌های سریع به ترانه‌ها گوش دهید تا انتخاب سریع خود ساخته شود مجموعه‌های منتشرشده‌ی جدید امروز دیروز این هفته هفته‌ی گذشته بیشترین ترانه‌های پخش‌شده بیشترین هنرمندهای پخش‌شده بیشترین مجموعه‌های پخش‌شده جستجو جستجو در یوتیوب موزیک… جستجوی کتاب‌خانه… کتاب‌خانه پسندشده بارگیری‌شده همه ترانه‌ها فیلم‌ها مجموعه‌ها هنرمندها فهرست‌پخش‌ها فهرست‌پخش‌های انجمن فهرست‌پخش‌های ویژه نشانک‌گذاری‌شده نتیجه‌ای پیدا نشد از کتابخانه شما آهنگ های پسندیده شده آهنگ های دانلود شده فهرست‌ پخش خالی است تلاش‌ مجدد رادیو مخلوط بازنشانی جزئیات ویرایش شروع رادیو پخش پخش بعدی اضافه‌کردن به صف اضافه‌کردن به کتاب‌خانه حذف از کتاب‌ خانه دانلود درحال دانلود لغو دانلود واردکردن فهرست‌ پخش اضافه‌کردن به فهرست‌ پخش مشاهده‌ هنرمند مشاهده‌ مجموعه نوسازی هم‌رسانی حذف حذف از تاریخچه جستجوی برخط همگام‌سازی پیشرفته تاریخ اضافه‌شده نام هنرمند سال تعداد آهنگ طول زمان پخش ترتیب سفارشی شناسه‌ی رسانه نوع رسانه رمزگشاها نرخ‌ذره نرخ نمونه بلندی حجم اندازه‌ی پرونده ناشناخته در بُریده‌دان رونوشت‌شد ویرایش متن‌ترانه جستجوی متن‌ترانه ویرایش ترانه عنوان ترانه هنرمند ترانه عنوان ترانه نمی تواند خالی باشد. هنرمند ترانه نمی تواند خالی باشد. ذخیره انتخاب فهرست‌پخش ویرایش فهرست‌پخش ایجاد فهرست‌پخش نام فهرست‌پخش نام فهرست‌پخش نمی تواند خالی باشد. ویرایش هنرمند نام هنرمند نام هنرمند نمی تواند خالی باشد. %d ترانه %d ترانه‌ها %d هنرمند %d هنرمندها %d مجموعه %d مجموعه‌ها %d فهرست‌پخش %d فهرست‌پخش‌ها %d هفته %d هفته‌ها %d ماه %d ماه‌ها %d سال %d سال‌ها فهرست‌پخش افزوده شد «%s» از فهرست‌پخش حذف شد فهرست‌پخش همگام‌شد واگرد متن‌ترانه پیدا نشد شمارنده‌ی خواب پایان ترانه %d دقیقه %d دقیقه جریانی در دسترس نیست بدون اتصال به تارکده اتمام وقت خطای ناشناخته پسندیدن حذف پسندیدن بُرزدن روشن بُرزدن خاموش حالت تکرار خاموش تکرار ترانه‌ی فعلی ترار صف همه‌ی ترانه‌ها ترانه‌های جستجوشده پخش‌کننده‌ی موسیقی تنظیمات ظاهر فعال‌کردن طرح پویا تم تاریک روشن خاموش پیروی از سیستم مشکی خالص زبانه باز پیش‌فرض سفارشی‌کردن زبانه‌های ناوبری موقعیت متن ترانه چپ وسط راست محتوا ورود زبان پیش‌فرض محتوا کشور پیش‌فرض محتوا پیش فرض سیستم فعال‌کردن پروکسی نوع پروکسی آدرس‌اینترنتی پروکسی راه‌اندازی‌مجدد برای اعمال اثر پخش‌کننده و صدا کیفیت صدا خودکار بالا پایین صف مداوم گذر از سکوت طبیعی‌سازی صدا میزان‌گر ذخیره‌سازی حافظه‌ی‌پنهان حافظه‌‌ی‌پنهان تصویر حافظه‌‌ی‌پنهان ترانه حداکثر اندازه‌ی حافظه‌ی‌پنهان نامحدود پاک‌کردن تمامی بارگیری‌ها بیشترین اندازه‌ی حافظه‌ی‌پنهان تصویر پاک‌کردن حافظه‌‌ی‌پنهان تصویر بیشترین اندازه‌ی حافظه‌ی‌پنهان ترانه پاک‌کردن حافظه‌‌ی‌پنهان ترانه %s استفاده‌شده‌است حریم‌خصوصی متوقف‌کردن تاریخچه‌ی گوش‌دادن پاک‌کردن تاریخچه‌ی گوش‌دادن آیا از پاک‌کردن تمامی سابقه‌ی گوش‌دادن مطمئن هستید؟ متوقف‌کردن تاریخچه جستجو پاک‌کردن تاریخچه جستجو آیا برای پاک‌کردن تمام سابقه جستجو مطمئن هستید؟ فعال‌کردن ارائه‌دهنده‌ی متن‌ترانه‌ی KuGou پشتیبان‌گیری و بازگردانی پشتیبان‌گیری بازگردانی فهرست‌پخش واردشد پشتیبان باموفقیت ایجادشد پشتیبان ایجاد نشد بازیابی پشتیبان انجام‌نشد درباره نسخه‌ی برنامه نسخه‌ی جدید دردسترس‌است نمونه‌های ترجمه پاک‌کردن نمونه‌های ترجمه موارد دلخواه فراموش شده به گوش دادن ادامه بده لیست های پخش یوتیوب شما شبیه به آهنگ‌ های کتابخانه اینجا نمایش داده می‌ شوند هنرمندان کتابخانه اینجا نمایش داده می شوند آلبوم‌ های کتابخانه اینجا نمایش داده می‌ شوند لیست‌های پخش شما اینجا نمایش داده می‌ شوند نسخه های دیگر آیا واقعاً می‌ خواهید همه آهنگ‌های لیست پخش «%s» را از حافظه آهنگ‌ های دانلود شده حذف کنید؟ آیا واقعاً می‌ خواهید لیست پخش «%s» را حذف کنید؟ همه را به کتابخانه اضافه کنید همه را از کتابخانه حذف کنید حذف از لیست پخش حذف از صف تمپو و زیر و بمی صدا تکراری ها ================================================ FILE: app/src/main/res/values-fi/strings.xml ================================================ Koti Kappaleet Artistit Albumit Soittolistat %d valittu %d kappaletta valittu Historia Tilastot Mielialat ja genret Tili Pikalista Kuuntele kappaleita luodaksesi pikavalintasi Uusia julkaisuja albumeita Tänään Eilen Tämä viikko Viime viikko Eniten soitetut kappaleet Eniten soitetut artistit Eniten soitetut albumit Etsi Hae YouTube Musicista… Hae kirjastosta… Kirjasto Tykätty Ladattu Kaikki Kappaleet Videot Albumit Artistit Soittolistat Yhteisön soittolistat Suositellut soittolistat Kirjanmerkkeihin lisätty Tuloksia ei löytynyt Kirjastostasi Tykkätyt kappaleet Ladatut kappaleet Soittolista on tyhjä Toisto Radio Sekoita Nollaa Yksityiskohdat Muokkaa Käynnistä radio Toista Toista seuraavaksi Lisää jonoon Lisää kirjastoon Poista kirjastosta Lataa Lataamassa Poista lataus Tuo soittolista Lisää soittolistaan Näytä artisti Näytä albumi Nouda uudelleen Jakaa Poista Poista historiasta Etsi verkossa Synkronoi Edistynyt Lisäyspäivä Nimi Artisti Vuosi Kappaleiden määrä Pituus Toistoaika Mukautettu tilaus Mediatunnus MIME-tyyppi Koodekit Bittinopeus Äänekkyys Voimakkuus Tiedoston koko Tuntematon Kopioitu leikepöydälle Muokkaa sanoituksia Hae sanoituksia Muokkaa kappaletta Kappaleen nimi Artisti Kappaleen nimi ei voi olla tyhjä. Kappaleen esittäjä ei voi olla tyhjä. Tallenna Valitse soittolista Muokkaa soittolistaa Luo soittolista Soittolistan nimi Soittolistan nimi ei voi olla tyhjä. Muokkaa artistia Artistin nimi Artistin nimi ei voi olla tyhjä. %d kappale %d kappaletta %d artisti %d artistia %d albumi %d albumia %d soittolista %d soittolistaa %d viikko %d viikkoa %d kuukausi %d kuukautta %d vuosi %d vuotta Soittolista tuotu \"%s\" poistettiin soittolistasta Soittolista synkronoitu Kumoa Sanoitusta ei löytynyt Uniajastin Laulun loppu %d minuutti %d minuuttia Suoratoistoa ei saatavilla Ei verkkoyhteyttä Aikakatkaisu Tuntematon virhe Tykkää Poista \"tykkää\" Sekoitus päällä Sekoitus pois päältä Toistotila pois päältä Toista nykyinen laulu Toista jono Kaikki laulut Soitin Asetukset Ulkoasu Tumma teema Käytössä Pois käytöstä Järjestelmän teeman mukainen Sisältö Sisällön oletuskieli Sisällön oletusmaa Järjestelmän oletus Tietoa Sovelluksen versio Lisää kaikki kirjastoon Poista kaikki kirjastosta Poista jonosta Poista soittolistasta Jono Pieni Ei kirjautunut sisään Samanlaisia kuin Tempo ja sävelkorkeus Tykkää kaikista Poista kaikki tykkäykset Teema Soitin Sivulla Automaattisesti lataa lisää kappaleita Automaattisesti lisää enemmän kappaleita, kun jonon loppu saavutetaan, jos mahdollista Automaattisesti ohita seuraavaan kappaleeseen, kun tapahtuu virhe Kuunteluhistoria Ohita kaksoiskappaleet Poista kuvakaappaukset käytöstä Discord-integraatio Suuri Kirjaston artistit näkyvät täällä Jatka kuuntelemista YouTube-soittolistasi Kirjaston kappaleet näkyvät täällä Kirjaston albumit näkyvät täällä Soittolistasi näkyvät täällä Muita versioita Haluatko varmasti poistaa kaikki \"%s\" soittolistan kappaleet Ladattujen kappaleiden tallennustilasta? Haluatko varmasti poistaa soittolistan \"%s\"? Kaksoiskappaleet Lisää silti Laulu on jo soittolistassasi %d kappaleet ovat jo soittolistassasi Soittimen tekstin tasaus Soittimen liukusäätimen tyyli Oletus Koukeroinen Ruudukon solun koko Palauta viimeinen jonosi, kun sovellus käynnistyy Varmista jatkuva toistokokemus Pysäytä musiikki, kun sovellus suljetaan Hakuhistoria Kun tämä asetus on päällä, kuvakaappaukset ja sovelluksen näkyminen äskeisissä sovelluksissa poistetaan käytöstä. Ota LrcLib-lyriikantarjoaja käyttöön Piilota sopimaton sisältö Hylkää Metrolist käyttää KizzyRPC-kirjastoa asettaaksesi Discord-tilisi tilan. Tämä edellyttää Discord Gateway-yhteyttä, jota voidaan katsoa Discordin käyttöehtojen rikkomuksena. Ei kuitenkaan ole tiedossa tapauksia, joissa käyttäjätilit olisi jäädytetty tästä syystä. Käytä omalla vastuullasi. \n \nMetrolist poimii ainoastaan tunnuksesi, ja kaikki muu säilytetään paikallisesti. Asetukset Esikatselu Kirjautuminen epäonnistui Kirjaudu ulos Tämä on Discord-integraatio-ominaisuus, se tarkoittaa Metrolist-toiminnan näyttämistä Discord-tililläsi Unohtuneet suosikit Näytteenottonopeus Haetut laulut Ota dynaaminen teema käyttöön Puhtaan musta Mukauta navigointivälilehtiä Sanoitustekstin sijainti Vasen Keskellä Oikea Sekalaiset Oletusarvoinen avausvälilehti Kirjaudu sisään Sisäänkirjautuminen Ota välityspalvelin käyttöön Välityspalvelimen tyyppi Välityspalvelimen URL-osoite Käynnistä uudelleen, jotta se tulee voimaan Soitin ja ääni Äänenlaatu Automaattinen Korkea Matala Pysyvä jono Ohita hiljaisuus Äänen normalisointi Taajuuskorjain Tallennustila Välimuisti Kuvavälimuisti Lauluvälimuisti Välimuistin enimmäiskoko Rajoittamaton Tyhjennä kaikki lataukset Kuvavälimuistin enimmäiskoko Tyhjennä kuvavälimuisti Laulujen välimuistin enimmäiskoko Tyhjennä laulujen välimuisti %s käytetty Tietosuoja Keskeytä historian kuuntelu Tyhjennä kuunteluhistoria Oletko varma, että haluat tyhjentää koko kuunteluhistorian? Keskeytä hakuhistoria Tyhjennä hakuhistoria Oletko varma, että haluat tyhjentää kaiken hakuhistorian? Käytä kirjautumista sisällön selaamista varten Tämä voi vaikuttaa näkemääsi sisältöön ja näyttää esimerkiksi vain ensiluokkaisia albumeita, jos olet kirjautunut sisään Premium-tilillä Ota KuGou-sanoitukset käyttöön Varmuuskopiointi ja palautus Varmuuskopio Palauta Tuotu soittolista Varmuuskopio luotu onnistuneesti Varmuuskopiota ei voitu luoda Varmuuskopion palauttaminen epäonnistui Uusi versio saatavilla Käännösmallit Tyhjennä käännösmallit ================================================ FILE: app/src/main/res/values-fil/metrolist_strings.xml ================================================ Lokal Mga tsart Balik Nangungunang music videos Sumisikat Linggo Buwan Taon Tuloy tuloy Nagustuhan Nadownload Aking nangunguna Nakacache Naka upload Naka upload Tala: Ito ay pumapayag mag sync sa Youtube Music. HINDI PWEDE ibahin to mamaya. Gumagawa ng imahe Pakiantay Kanselahin Ishare ang lyrics Ishare bilang teksto Ishare bilang imahe Todong limit ng seleksyon Ishare ang napili Icustomize ang kulay Kulay ng teksto Kulay ng pangalawang teksto Kulay ng paligid Tinanggal mula sa cache Kopyahin ang link Piliin lahat Ilike lahat Idislike lahat Petsa ng huling update Kinopya ang link sa clipboard Sinisimulan ang radyo Tumutugtog ngayon Itago ang thumbnail ng player Palitan ang artwork ng album ng logo ng aplikasyon sa player Nasa playlist na: Cover ng Album Remote ================================================ FILE: app/src/main/res/values-fil/strings.xml ================================================ ================================================ FILE: app/src/main/res/values-fr/metrolist_strings.xml ================================================ Favoris Hors-Ligne Mon Top Tout sélectionner Tout aimer Date de mise à jour Proxy Choix de la bibliothèque par défaut Choix des sélections rapides Basé sur le dernier titre écouté Tout le temps Dernières 24 heures Semaine dernière Mois dernier Année dernière Longueur de la liste Mon Top Dans d\'autres applications Meilleurs Clips Musicaux Remarque : Ceci permet la synchronisation avec YouTube Music. Ce paramètre ne peut pas être modifié ultérieurement. Copier le lien Ne pas aimer tout Retirer du cache Lien copié dans le presse-papiers Barre de navigation inférieure mince Paroles Déjà dans la playlist : Faites glisser le titre vers la gauche pour l\'ajouter à la file d\'attente ou vers la droite pour le lire ensuite Afficher la playlist « Top » Afficher la playlist « Mise en Cache » Ceci est une méthode de connexion AVANCÉE. En tant qu\'alternative au portail web, vous devez directement entrer ou mettre à jour votre jeton de connexion ici. Par exemple, ceci peut accélérer la connexion sur plusieurs appareils. Veuillez noter que tout format de jeton invalide que l\'application ne parvient pas à analyser ne sera pas accepté %d seconde %d secondes %d secondes Impossible d\'ouvrir les paramètres de l\'application Locale Classements Retour Pochette d\'album Tendance Semaines Mois Années Continu Mise en Cache Synchroniser la Playlist Synchronisation désactivée %d fois %d fois %d fois Contenu similaire Style de l\'arrière plan du lecteur Suivre le thème Dégradé Flou Couleurs des boutons du lecteur Défaut Activer le glissement pour changer de titre Changer les paroles avec un clic Subtil Playlists Automatiques Afficher la playlist « Favoris » Afficher la playlist « Hors-Ligne » Connexion avec un jeton Cliquer pour afficher le jeton Cliquer encore pour copier ou éditer Général Langue de l\'application Activer le contenu similaire Ajoutez automatiquement d\'autres titres similaires lorsque la fin de la file d\'attente est atteinte %d%% Êtes-vous sûr de vouloir effacer tous les titres en cache ? Êtes-vous sûr·e de vouloir supprimer tous les téléchargements ? Non connecté à YouTube Ouvrir les liens supportés Notes de version Durée de l\'historique Informations Description Vues J\'aime Je n\'aime pas Annuler Partager les paroles Partager comme texte Limite maximale de sélection Partager la sélection Personnaliser les couleurs Couleur du texte Couleur secondaire du texte Couleur d\'arrière-plan Télécharger automatiquement quand on aime Téléchargez automatiquement les titres quand vous les aimez Génération de l\'image Partager comme image Veuillez patienter Défilement automatique des paroles Remarque : L’ajout de titres locaux à des playlists synchronisées/distantes n’est pas pris en charge. Toute autre combinaison est valide. Importer une playlist « m3u » Importer des playlists au format CSV Romaniser les paroles japonaises Romaniser les paroles coréennes Synchronisation automatique avec le compte Plus de contenu Nouveau design du lecteur Sensibilité du mini-lecteur au balayage Êtes-vous sûr de vouloir effacer toutes les images mises en cache ? Désactiver %1$d%% S\'abonner Abonné Nouveau design du mini lecteur En cours de lecture +%1$d secondes en avant -%1$d secondes en arrière Recherche progressive Si cette option est activée, chaque nouvel appui pour avancer ou reculer ajoute 5 secondes de plus que le saut précédent Fermer Masquer la miniature du lecteur Remplacer la pochette de l\'album par le logo de l\'application dans le lecteur Désactiver le chargement supplémentaire lors de la répétition de tout Ne chargez pas automatiquement plus de titres et de contenu similaire lorsque le mode répéter tout est activé Interface Confidentialité & Sécurité Lecteur & Contenu Stockage & Données Système & À propos Démarrage de la radio Configurer le proxy Nom d\'utilisateur du proxy Mot de passe proxy Activer l\'authentification Cyrillique Romanisation Romanisation des paroles Romaniser les paroles russes Romaniser les paroles ukrainiennes Romaniser les paroles biélorusses Romaniser les paroles kirghizes Romaniser les paroles serbes Romaniser les paroles bulgares EXPÉRIMENTAL : Détecter la langue ligne par ligne La langue cyrillique sera détectée ligne par ligne au lieu du titre entier. Êtes-vous sûr ? Il s\'agit d\'une fonctionnalité expérimentale aléatoire.\n\nPar défaut, la langue est déterminée à partir du titre entier, mais avec cette option activée, il sera déterminé ligne par ligne. Cela permettra aux titres multilingues de fonctionner, mais la langue pourrait ne pas être toujours correcte (par exemple, si des paroles en ukrainien ne contiennent aucune lettre spécifique à l\'ukrainien, elles pourraient être romanisées en russe).\n\nSi vous ne rencontrez pas de problème, il est recommandé de désactiver cette option. Romaniser la piste actuelle Modifier la couverture de la playlist Remarque : votre compte doit être lié à un numéro de téléphone et vérifié sur YouTube Music pour modifier la couverture de la playlist. Après avoir sélectionné une image, veuillez patienter un instant pour que la nouvelle couverture apparaisse dans votre playlist. Choisissez dans la bibliothèque Supprimer l\'image personnalisée Activer le déchargement Utilisez le chemin audio de déchargement pour la lecture audio. Désactiver cette option peut augmenter la consommation d\'énergie, mais peut s\'avérer utile en cas de problèmes de lecture audio ou de post-traitement Afficher la playlist « Téléchargés » Téléchargés Téléchargés Mise à jour Vérifier automatiquement les mises à jour Romaniser les paroles en macédonien Activer les notifications de mise à jour Mise à jour disponible Mises à jour de l\'application Notifications sur les nouvelles versions Utiliser les détails au lieu de l\'état Afficher le titre de la chanson en évidence au lieu des noms des artistes Intégrations Nom d\'utilisateur Mot de passe Intégration Last.fm Activer le scrobbling Envoyer en cours de lecture Configuration du scrobbling Chansons de Scrobble plus longues que Pourcentage de retard du Scrobble Minutes de retard du Scrobble Faites glisser le titre pour le supprimer de la playlist Envoyer des mentions « J’aime »/« Je n’aime pas » Titres aimés/détestés sur Last.fm selon qu\'ils soient aimés/détestés dans Metrolist Paroles chinoises romanisées Google Cast Activer la diffusion audio sur Chromecast et autres appareils compatibles Cast Masquer les chansons vidéo Couleur primaire Resynchroniser Consultez les informations sur le titre Modifier le titre ou l\'artiste Créez une station basée sur cet élément Ajouter en haut de votre file d\'attente Ajouter en bas de votre file d\'attente Enregistrer dans votre bibliothèque Rendre disponible pour la lecture hors ligne Ajouter à l\'une de vos playlists Récupérez les métadonnées les plus récentes de YouTube Music Partager un lien vers cet élément Supprimer définitivement cet élément Modifiez le tempo et la hauteur du titre Réglez l\'égaliseur audio Activer l\'icône dynamique Mini-lecteur Mini-lecteur noir pur Attendez ! Vous avez choisi une limite de taille de cache inférieure à celle actuellement utilisée par l\'application (%1$s). Si vous continuez, l\'application risque de supprimer une partie du cache %2$s pour respecter la nouvelle limite. Voulez-vous continuer malgré tout ? Continuer Couleur tertiaire Connexion… Télécharger tous les titres pour une écoute hors ligne Supprimer tous les titres téléchargés de cette playlist Le téléchargement est en cours Partager cette playlist avec d\'autres personnes Supprimer définitivement cette playlist Synchroniser la playlist avec YouTube Music Activer Better Lyrics Paroles synchronisées syllabe par syllabe pour n\'importe quelle chanson, pour le karaoké Style d\'animation mot à mot Aucun Disparition Brillance Glissement Karaoké Apple Music Taille du texte des paroles Espacement des lignes des paroles Lecture aléatoire de la playlist/de l\'album en premier En mode aléatoire, lire d\'abord tous les titres de la playlist/album d\'origine, puis les titres similaires Montrer le résumé Pochette d\'album pour %s Vous avez écouté albums uniques Votre album préféré est Votre playlist personnelle est prête Vos 5 albums préférés Vous avez écouté cet album pendant %d minutes %d minutes Aucune donnée Vos artistes préférés de l\'année %d minutes Vos titres préférés de l\'année Pochette d\'album Votre artiste préféré de l\'année est Image de l\'artiste principal Vous les avez écoutés pendant %d minutes Votre titre le plus écouté est Vous avez écouté pendant %d minutes Vous avez écouté artistes uniques Vous avez écouté titres uniques METROLIST Il est temps de voir ce que vous avez écouté allons-y ! Logo Metrolist 2025 VOTRE CADEAU EST PRÊT À ÊTRE DÉVOILÉ ! Il est temps de voir ce que vous avez aimé cette année. Merci de votre écoute Remerciements particuliers à MO Agamy pour la création de Metrolist Fermer le résumé Votre %s en résumé Créer une playlist Playlist enregistrée Conversion en %s Progression %s%% Écouter Metrolist Ouvrir Échec de la création de l\'image : %s Titre copié Artiste copié Erreur de lecture Impossible d\'analyser l\'URL du proxy. Activer l\'effet de paroles lumineuses Ajouter une animation lumineuse et un effet de rebond aux paroles actives Ondulé %d Profil %d Profils %d Profils Égaliseur Aucun profil d\'égaliseur Importer un profil Désactivé Supprimer le profil Êtes-vous sûr de vouloir supprimer %1$s ? Cette action est irréversible. Impossible de lire le fichier Impossible d\'ouvrir le fichier : %1$s Erreur d\'importation %d bande %d bandes %d bandes Mettre la musique en pause lorsque le son est coupé Activer les paroles de SimpMusic Paroles automatiquement fournies par Musixmatch et YouTube Transcript Égaliseur système Pochette d\'album Aucun titre en cours de lecture Appuyez pour ouvrir Metrolist Précédent Lecture/Pause Suivant Widget de lecteur de musique avec commandes de lecture Widget musical circulaire avec commandes de lecture et d\'appréciation J\'aime Se souvenir de la lecture aléatoire et de la répétition Se souvenir du mode aléatoire et répétition lors du redémarrage de l\'application Décalage des paroles À propos Afficher plus Afficher moins Page de l\'artiste Afficher la description de l\'artiste Afficher le nombre d\'abonnés Afficher les auditeurs mensuels Avance rapide pendant les parties silencieuses des titres Ignorer instantanément les silences Avancez pendant les silences au lieu d\'accélérer la lecture Gardez la lecture aléatoire activée lorsque vous démarrez de nouveaux titres ou playlists Lecture aléatoire continue Échec de la lecture Erreur Échec de l\'application du profil d\'égalisation : %1$s Recadrer la pochette d\'album Forcer un format carré en recadrant les miniatures vidéo Garder l\'écran allumé lorsque le lecteur est agrandi Écouter ensemble URL du serveur Nom d\'utilisateur Connecté Reconnexion… Déconnecté Connexion en cours… Erreur de connexion Créer une salle Créez une salle et partagez le code avec vos amis Rejoindre la salle Code de la salle Vous êtes l\'hôte Vous êtes un invité Demandes d\'adhésion Afficher les journaux Débogage de la connexion et des messages Journaux de connexion Pas encore de commentaires Écoutez de la musique avec vos amis en temps réel. Créez une salle pour en être l\'hôte ou rejoignez une salle existante à l\'aide d\'un code. Remarque : vous risquez d\'être déconnecté si vous créez une salle alors qu\'aucune musique n\'est en cours de lecture, puis que vous passez à une autre application. L\'option « Écouter ensemble » n\'est pas configurée. Veuillez configurer l\'URL du serveur dans Paramètres → Intégrations → Écouter ensemble. %1$s a demandé %2$s Suggestion envoyée à l\'hôte ! %1$s souhaite rejoindre la salle Écouter ensemble Notifications pour les événements « Écouter ensemble » Salle créée : %s Impossible de modifier le nom d\'utilisateur lorsque vous êtes dans une salle En attente de l\'approbation de l\'hôte Code de salle invalide Demande d\'adhésion refusée Rejoindre une salle existante Code de la salle Quitter la salle Rejoindre Créer Rejoindre la salle %s… Création de la salle… Déconnecter Créer Rejoindre Approuver Rejeter Copier Copié dans le presse-papiers Non défini Salle d\'accueil Demandes en attente Suggestions en attente Suggérer à l\'hôte Hôte Vous Utilisateurs connectés Entrez votre nom d\'utilisateur Un nom d\'utilisateur est requis. Resynchronisation Connecter Effacer Dans la salle Coup Muet Réactiver le son L\'application a planté Une erreur inattendue s\'est produite. Veuillez partager le rapport d\'erreur afin de nous aider à résoudre le problème. Partager les journaux Partager le rapport d\'incident Rapport d\'incident Metrolist Fermer Aucun rapport d\'incident disponible Dynamique Cramoisi Rose Violet Violet Profond Indigo Bleu Bleu Ciel Cyan Sarcelle Vert Vert Clair Citron Vert Jaune Ambre Orange Orange Foncé Marron Gris Bleu Gris Retour Mode Noir Profond Mode Clair Mode Sombre Mode Système Palette %1$s Choisir un serveur Serveur personnalisé Utiliser un serveur personnalisé Approuver automatiquement les demandes d\'adhésion Approuver automatiquement les demandes d\'adhésion au lieu de les examiner manuellement Les invités suivent le volume sonore indiqué par l\'hôte Synchroniser le volume hôte Copier le code Supprimer cette personne de la session Bloquer définitivement Bloquez les demandes d\'adhésion de cette personne et masquez ses suggestions Transfert de propriété Désignez cette personne comme l\'hôte de la salle Gérer les utilisateurs Utilisateurs bloqués %d utilisateur(s) bloqué(s) Aucun utilisateur bloqué Débloquer Utilisateur bloqué par l\'hôte Aucun titre en cours de lecture Appuyez pour ouvrir Metrolist Lecteur de Musique Platine vinyle Ensemble Entrez le code de la salle Configurer le serveur, le nom d\'utilisateur, et plus encore Traduction des paroles par IA Traduction des paroles... Paroles traduites Fournisseur URL de base Clé API Modèle Mode de traduction Langue cible Identifiants API Traduction Transcription Clé API requise Une clé API est requise Aucune parole à traduire Les paroles sont vides La langue cible est requise Résultat de traduction inattendu Une erreur inconnue s\'est produite Échec de la traduction Tout lire Reconnaître la musique Colonne URL YouTube (Facultatif) Réécouter Êtes-vous sûr de vouloir effacer tout l\'historique de reconnaissance ? Aucun résultat trouvé Supprimer de l\'historique Colonne Nom de l\'artiste Traitement en cours… Effacer l\'historique de reconnaissance Colonnes CSV de la carte Col %d Erreur de reconnaissance Forcer l\'affichage à fonctionner à la fréquence de rafraîchissement maximale prise en charge (par exemple, 120 Hz) La première ligne est l\'en-tête Réessayer Appuyez pour reconnaître Historique de reconnaissance Activer le taux de rafraîchissement élevé Colonne Titre de la chanson Récemment converti Importer un fichier CSV Jouer sur Metrolist Écoute… Continuer Activer Fondu enchaîné Fondu enchaîné entre les chansons Durée du fondu enchaîné Désactiver pour les albums sans interruption N\'utilisez pas de fondu enchaîné si l\'album est sans interruption Fonctionnalité Bêta Le fondu enchaîné est une nouvelle fonctionnalité qui peut présenter des bugs. Si vous rencontrez des problèmes, veuillez les signaler.\n\nCette fonctionnalité désactive le déchargement audio en raison de limitations techniques. Désactivé car le fondu enchaîné est actif Masquer YouTube Shorts Écouter ensemble dans la barre supérieure Afficher « Écouter ensemble » dans la barre d\'application supérieure au lieu de la barre de navigation Empêcher les doublons dans la file d\'attente Lorsque vous ajoutez un titre à la file d\'attente, supprimez-le de sa position précédente s\'il y figure déjà Traduire le sens dans la langue cible Convertir la prononciation en script cible Obtenir les clés API Consultez https://openrouter.ai pour accéder à des modèles gratuits et payants Consultez https://platform.openai.com/api-keys Consultez https://console.anthropic.com/settings/keys Consultez https://aistudio.google.com/apikey Consultez https://perplexity.ai/settings/api Consultez https://deepl.com/pro-api pour obtenir des clés gratuites et payantes Consultez https://console.x.ai Formalité Par défaut Plus formel Moins formel Statut En ligne Inactif Ne pas déranger Boutons Bouton 1 Bouton 2 Connexion réussie ! Cette fonctionnalité utilise la bibliothèque KizzyRPC pour se connecter à la passerelle Discord et définir votre statut Rich Presence. Bien qu\'aucune suspension de compte n\'ait été signalée suite à une utilisation similaire, cette méthode n\'est pas officiellement prise en charge par Discord et peut être considérée comme une violation des conditions d\'utilisation. Votre jeton est extrait localement et n\'est jamais envoyé à des serveurs tiers. Procédez à votre propre discrétion. Type d\'activité Lire Écouter Regarder En compétition Variables : {song_name}, {artist_name}, {album_name} Aperçu de la présence enrichie Présence Connectez-vous avec Discord pour partager ce que vous écoutez Lire sur Metrolist Regarder sur Metrolist En compétition dans Metrolist Nom de l\'activité Nom personnalisé pour l\'activité (laisser vide pour utiliser le nom par défaut) Mode avancé Afficher les options de personnalisation supplémentaires pour la présence enrichie Solide Reprise de la connexion Bluetooth Romaniser les paroles en hindi Romaniser les paroles en pendjabi Afficher les paroles romanisées comme paroles principales Licence publique générale GNU v3.0 Software gratuit et open source. L\'utilisation, la recherche, le partage et les améliorations sont autorisés. Serveur Discord Chaîne Telegram Site internet Instagram GitHub Consulter le dépôt %1$s • %2$s METROLIST Aucun compte trouvé Vérification du compte précédent… Restaurer Vous devrez vous reconnecter après la restauration. Le compte suivant sera déconnecté : Cela restaurera les données de votre application à partir de la sauvegarde. Restaurer la sauvegarde ? %d épisode %d épisodes %d épisodes Densité d\'affichage Redémarrer Redémarrage requis Le changement de densité d\'affichage prendra effet après le redémarrage de l\'application. Voulez-vous redémarrer maintenant ? Base de données communautaire de paroles synchronisées Utilise des paroles de KuGou, une plateforme musicale chinoise populaire REMARQUE : Les paroles de YouTube Music s’afficheront automatiquement si aucune autre source n’est disponible. Les paroles provenant de YouTube Music ne sont généralement pas synchronisées. Activer LyricsPlus Paroles synchronisées provenant de plusieurs sources Sélection du fournisseur Choisissez les fournisseurs de paroles activés Priorité du fournisseur de paroles Faites glisser pour réorganiser les fournisseurs selon vos préférences. Position plus élevée -> priorité plus élevée. Journal des modifications Aucun journal des modifications disponible https://github.com/MetrolistGroup/Metrolist/releases Voir sur GitHub Version actuelle Version : %s Paramètres de mise à jour Vérification des mises à jour Recherche de mises à jour… Dernière mise à jour : %s Vérifier les mises à jour Masquer le journal des modifications Afficher le journal des modifications Échec de la vérification des mises à jour : %s Définir comme valeur par défaut Minuterie de mise en veille réglée par défaut sur %d min Situé dans Paramètres > Contenu lectures Impossible de sauvegarder l\'épisode Impossible de supprimer l\'épisode Impossible de s\'abonner au podcast Impossible de se désabonner du podcast Approuver automatiquement les suggestions de titres Approuver automatiquement et mettre en file d\'attente les suggestions de titres des invités Accès Rapide Épingler à l\'Accès Rapide Détacher de l\'Accès Rapide Ordre aléatoire de l\'écran d\'accueil Réorganiser aléatoirement les sections de l\'écran d\'accueil selon des priorités pondérées Cela ressemble à %1$s Parce que vous écoutez %1$s Similaire à %1$s Basé sur %1$s Pour les fans de %1$s De la communauté Conserver les données de la bibliothèque ? Souhaitez-vous conserver vos playlists et les données de votre bibliothèque ? Les titres téléchargés seront conservés dans tous les cas. Conserver Effacer Développeur Principal Collaborateur Collaborateurs Vous aimez ce que je fais ? Offrez-moi un café Communauté & Informations Envie de jouer leur titre préféré ? Oui Ce projet soutient la Palestine 🇵🇸 Podcasts Voir le podcast Chaînes de podcast Derniers épisodes Vos émissions Nouveaux épisodes Épisodes à venir Enregistrer pour plus tard Ajouter à votre playlist « Épisodes à venir » Supprimer des enregistrements Enregistrer le podcast dans la bibliothèque Importer une playlist Reconnaissance Musicale Identifiez les titres diffusés autour de vous directement depuis votre écran d\'accueil Appuyez pour identifier le titre En cours d\'écoute… Identification… Aucun résultat trouvé. Veuillez réessayer. Échec de la reconnaissance Une erreur s\'est produite. Veuillez réessayer. Titre inconnu Artiste inconnu Identifier un titre Reconnaissance Musicale Affiche une notification lors de l\'identification d\'un titre à partir du widget Enregistrement audio pour identifier le titre… Épisodes Chaînes Playlist Automatique Épisodes téléchargés Aucune chaîne abonnée Aucun épisode téléchargé %d chaîne %d chaînes %d chaînes Voir la chaîne Profils Activer la minuterie de mise en veille automatique Active automatiquement la minuterie de mise en veille avec la valeur par défaut à une durée personnalisée. Définissez une date et une heure personnalisées pour l\'activation automatique de la minuterie de sommeil. Répéter Quotidien Du Lundi au Vendredi En semaine / Week-ends Week-ends (Samedi-Dimanche) Personnalisé Heure de début Heure de fin Lundi Mardi Mercredi Jeudi Vendredi Samedi Dimanche Arrêter à la fin du titre en cours lorsque le minuteur arrive à son terme S\'éteint dans la dernière minute Télécharger des titres Téléchargement en cours… %1$d de %2$d Téléchargement terminé Échec du téléchargement Fichier trop volumineux (max. 300 Mo) Format non pris en charge. Utilisez les formats mp3, m4a, wma, flac ou ogg. Supprimer le titre téléchargé Êtes-vous sûr de vouloir supprimer ce titre ? Cette action est irréversible. Titre téléchargé supprimé Échec de la suppression du titre téléchargé Supprimer les titres téléchargés Êtes-vous sûr de vouloir supprimer %1$d titres téléchargés ? Cette action est irréversible. Suppression de %1$d titres Suppression en cours… Exporter la playlist Exporter au format CSV Exporter au format M3U Playlist exportée avec succès Échec de l\'exportation de la playlist Partager Enregistrer dans Documents Reconnaître la musique ================================================ FILE: app/src/main/res/values-fr/strings.xml ================================================ Accueil Titres Artistes Albums Playlists %d sélectionné %d sélectionnés %d sélectionnés Historique Statistiques Humeurs et Genres Compte Sélection Rapide Écoutez quelques titres pour générer votre sélection rapide Nouveautés Aujourd\'hui Hier Cette semaine La semaine dernière Titres les plus joués Artistes les plus joués Albums les plus joués Rechercher Rechercher sur YouTube Music… Rechercher dans votre bibliothèque… Bibliothèque Favoris Téléchargés Tout Titres Vidéos Albums Artistes Playlists Playlists de la communauté Playlists mises en avant Favoris Aucun résultat trouvé De votre bibliothèque Titres favoris Titres téléchargés La playlist est vide Réessayer Radio Lecture aléatoire Réinitialiser Détails Modifier Démarrer la radio Lecture Suivant Ajouter à la file d\'attente Ajouter à la bibliothèque Supprimer de la bibliothèque Télécharger Téléchargement Supprimer le téléchargement Importer une playlist Ajouter à une playlist Voir l’artiste Voir l’album Récupérer Partager Effacer Supprimer de l’historique Rechercher en ligne Synchroniser Avancé Date d’ajout Nom Artiste Année Nombre de titres Durée Temps d’écoute Personnalisé Identifiant du média Type MIME Codecs Débit Taux d’échantillonnage Intensité Volume Taille du fichier Inconnu Copié dans le presse-papiers Modifier les paroles Rechercher les paroles Éditer le titre Titre Artistes du titre Le titre de la chanson ne peut pas être vide. L’artiste de la chanson ne peut pas être vide. Enregistrer Choisir une playlist Modifier la playlist Créer une playlist Nom de la playlist Le nom de la playlist ne peut pas être vide. Éditer l’artiste Nom de l’artiste Le nom de l’artiste ne peut pas être vide. %d titre %d titres %d titres %d artiste %d artistes %d artistes %d album %d albums %d albums %d playlist %d playlists %d playlists %d semaine %d semaines %d semaines %d mois %d mois %d mois %d an %d ans %d ans Playlist importée « %s » retiré de la playlist Playlist synchronisée Annuler Paroles introuvables Minuterie de Sommeil Fin du titre %d minute %d minutes %d minutes Aucun flux disponible Aucune connexion réseau Temps libre Erreur inconnue J’aime Je n’aime plus Lecture aléatoire activée Lecture aléatoire désactivée Répétition désactivée Répéter le titre actuel Répéter la liste d’attente Tous les titres Titres recherchés Lecteur de musique Paramètres Apparence Activer le thème dynamique Thème sombre Activé Désactivé Suivre le système Noir profond Menu ouvert par défaut Personnaliser les menus de navigation Position du texte des paroles Gauche Centre Droite Contenu Connexion Langue du contenu par défaut Pays du contenu par défaut Système par défaut Activer un proxy Type de proxy URL du proxy Redémarrer pour prendre effet Lecteur et Audio Qualité audio Automatique Élevée Faible File d’attente persistante Ignorer le silence Normalisation audio Égaliseur Stockage Cache Cache d’images Cache des titres Taille maximale du cache Illimitée Effacer tous les téléchargements Taille maximale du cache d’images Effacer le cache d\'images Taille maximale du cache des titres Effacer le cache des titres %s utilisé Confidentialité Suspendre l’historique d’écoute Effacer l’historique d’écoute Voulez-vous vraiment effacer tout l’historique d’écoute ? Suspendre l\'historique de recherche Effacer l\'historique de recherche Voulez-vous vraiment effacer tout l’historique des recherches ? Activer le fournisseur de paroles KuGou Sauvegarder et Restaurer Sauvegarder Restaurer Playlist importée Sauvegarde créée avec succès Impossible de créer la sauvegarde Échec de la restauration de la sauvegarde À propos Version de l\'application Nouvelle version disponible Modèles de traduction Effacer les modèles de traduction Non connecté Retirer de la playlist Doublons Passer les doublons Ajouter quand même Le titre est déjà dans votre playlist %d titres sont déjà dans votre playlist Alignement du texte du lecteur Sur le côté Activer le fournisseur de paroles LrcLib Masquer le contenu explicite Intégration Discord Abandonner Options Prévisualisation Se déconnecter Activer la présence riche Échec de la connexion Voulez-vous vraiment supprimer la playlist « %s » ? Historique d\'écoute Désactiver les captures d\'écran Historique de recherche Lorsque cette option est activée, les captures d\'écran et l\'affichage de l\'application dans Récents sont désactivés. Arrêter la musique en fermant la page Favoris oubliés Continuez d\'écouter Similaire à Les titres de la bibliothèque s\'afficheront ici Les artistes de la bibliothèque s\'afficheront ici Vos playlists s\'afficheront ici Autres versions Voulez-vous vraiment supprimer tous les titres de la playlist « %s » du stockage des titres téléchargés ? Tout ajouter à la bibliothèque Tempo et Pitch Tout marquer comme aimé Retirer les j\'aime Thème Lecteur Par défaut Ondulé Divers Taille des cellules de la grille Ajouter automatiquement, si possible, des titres à la fin de la file d\'attente quand elle est atteinte Permet d\'assurer une lecture continue Les albums de la bibliothèque s\'afficheront ici Vos playlists YouTube Tout supprimer de la bibliothèque Retirer de la file d\'attente Petit Style de curseur du lecteur Grand Restaurer la file d\'attente lorsque l\'application est relancée File d\'attente Charger automatiquement plus de titres Aller au titre suivant si une erreur apparaît Metrolist utilise la bibliothèque KizzyRPC pour définir le statut de votre compte Discord. Cela implique l\'utilisation de la connexion Discord Gateway, ce qui peut être considéré comme une violation des conditions d\'utilisation de Discord. Cependant, il n\'existe aucun cas connu de comptes d\'utilisateurs suspendus pour cette raison. Utilisez-le à vos propres risques. \n \nMetrolist extraira uniquement votre jeton, et tout le reste sera stocké localement. Utiliser le compte connecté pour parcourir le contenu Ceci peut influencer le contenu que vous voyez et affiche par exemple les albums Premium si vous êtes connecté avec un compte Premium Se connecter ================================================ FILE: app/src/main/res/values-hi/metrolist_strings.xml ================================================ स्थानीय पीछे एल्बम आवरण ================================================ FILE: app/src/main/res/values-hi/strings.xml ================================================ गाने मुख्य स्थान कलाकार एलबम %d चुना गया %d चुने गए खाता झटपट चलाने हेतु चुनिंदा गाने झटपट सूची बनाने के लिए कुछ गाने सुनें भूले-बिछड़े पसंदीदा गाने इतिहास आंकड़े मूड और शैलियां चाल सूचियां सुनते रहें आपकी यूट्यूब चाल सूचियाँ इसके जैसे आज कल अधिकतम सुने गए गाने अधिकतम सुने गए कलाकार खोजें संग्रह में खोजें… संग्रह पसंद किए गए डाउनलोड किए गए सब गाने वीडिओ एलबम कलाकार सामाज द्वारा चाल सूचियां प्रदर्शित चाल सूचियां कोई परिणाम नहीं संग्रह के कलाकार यहाँ दिखेंगे आपके संग्रह से अन्य संस्करण पसंद किए गए गाने डाउनलोड किए गए गाने चाल सूची खाली है क्या आप वाकई “%s” चाल सूची को हटाना चाहते हैं ? पुनः करें रीसेट करें विवरण बदलाव चलाएं अगला इसे बजायें कतार में लगाएं संग्रह में जोड़ें चाल सूची आयात करें चाल सूची मे जोड़ें पुनः निकालें हटायें ऑनलाइन खोजें सिंक इस हफ्ते नईं रिलीज हुईं एलबम पिछले हफ्ते अधिकतम सुनीं गईं एलबम यूट्यूब म्यूजिक में खोजें… चाल सूचियां चिह्नित संग्रह के गाने यहाँ दिखेंगे आपकी चाल सूचियां यहाँ दिखेंगीं संग्रह की एलबमें यहाँ दिखेंगीं क्या आप वाकई “%s” चाल सूची के सभी गानों को डाउनलोड किए गए गानों के भंडार से हटाना चाहते हैं ? आकाशवाणी फेंटें आकाशवाणी आरंभ करें सभी को संग्रह में जोड़ें सभी को संग्रह से हटायें संग्रह से हटाएं डाउनलोड करें डाउनलोड हो रहा डाउनलोड हटाएँ एलबम देखें कलाकार देखें भेजें इतिहास से हटायें चाल सूची से हटायें कतार से निकालें उन्नत गति व स्वरमान जुडने की तारीख कलाकार लंबाई बजाए जाने की अवधि आपका क्रम एम आइ एम ई का प्रकार कूटलेखक बिटदर नमूनाकरण दर प्रबलता प्रबलता फाइल का माप अज्ञात क्लिपबोर्ड मे लिया गया गीतिकाव्य बदलें गाने मे बदलाव करें गाने का नाम गाने के कलाकार के नाम को खाली नहीं छोड़ा जा सकता । चाल सूची चुनें तब भी जोड़ें %d गाने पहले से आपकी चाल सूची में हैं चाल सूची आयात की गई \"%s\" चाल सूची से हटाया गया चाल सूची समकालिक की गई पूर्ववत् करें %d एलबम %d एलबमें %d चाल सूची %d चाल सूचियां नाम वर्ष गीत संख्या मीडिया आइडी गीतिकाव्य ढूंढें गाने के कलाकार गाने के नाम को खाली नहीं छोड़ा जा सकता । चाल सूची बनाएं सहेजें चाल सूची मे बदलाव करें चाल सूची के नाम को खाली नहीं छोड़ा जा सकता । कलाकार बदलें कलाकार का नाम चाल सूची का नाम यह गाना पहले से आपकी चाल सूची में है कलाकार के नाम को खाली नहीं छोड़ा जा सकता । प्रतिरूप प्रतिरूप छोड़ें %d हफ्ता %d हफ्ते %d गाना %d गाने %d महीना %d महीने गीतिकाव्य नहीं मिला खोजे गए गाने %d साल %d साल सभी गाने सेटिंग्स थीम बंद कोई स्ट्रीम उपलब्ध नहीं है %d कलाकार %d कलाकार कोई नेटवर्क कनेक्शन नहीं चालू स्लीप टाइमर गाने का अंत %d मिनट %d मिनट समय सीमा अज्ञात त्रुटि पसंद सभी को पसंद करे पसंदीदा से हटाएं पसंदीदा से सभी को हटाएं सफल चालू सफल बंद दोहराएं मोड बंद कतार को दोहराएं प्लेयर टेक्स्ट की स्थिति संगीत प्लेयर प्रदर्शन डार्क थीम सिस्टम के अनुसार शुद्ध काला नेविगेशन टैब्स सेट करें वर्तमान गीत दोहराएं डायनामिक थीम सक्षम करें संगीत प्लेयर गीतिकाव्य पाठ की स्थिति दो पक्षीय बाए मध्य दाए प्लेयर स्लाइडर का प्रकार छोटा बड़ा सभी डौन्लोडस निकाल दे विविध डिफ़ॉल्ट खुला टैब डिफॉल्ट लॉग आउट लॉग इन लॉगिन लॉगिन असफल रहा ================================================ FILE: app/src/main/res/values-hr/metrolist_strings.xml ================================================ Liste Natrag Naslovnica albuma Najgledaniji glazbeni spotovi U trendu Tjedni Mjeseci Godine Stalno Lajkano Preuzeto Moja najbolja Predmemorirano Sinkroniziraj popis za reprodukciju Sync onemogućen Napomena: Ovo omogućuje sinkronizaciju s YouTube Music. Ovo NIJE moguće kasnije promijeniti. Generiram sliku Molim pričekajte Odustani Podijeli tekst pjesme Podijeli kao tekst Podijeli kao sliku Lokalno Udaljeno Maksimalno ograničenje odabira Podijeli odabrano Prilagodi boje Tekstualna boja Sekundarna boja teksta Boja pozadine Uklonite iz predmemorije Kopiraj vezu Odaberite sve Slično svemu Ne volim sve Datum ažuriranja Veza kopirana u međuspremnik Tekst Već je u predlošku: %d put %d puta %d puta Sličan sadržaj Stil pozadine reproduktora Pratite temu Gradijent Ogavan izgled reproduktora Novi izgled minijaturnog reproduktora Zamućenje Boje dugmeta reproduktora Zadana vrijednost Omogući prebacivanje prstom za promjenu pjesme Proslijedi pjesmu desnom prstom da je dodaješ u red čekanja ili lijevim prstom da je odmah riješiš Promijeni stihove po kliku Automatsko scrollanje teksta pjesme Romanizacija japanskog teksta Romanizacija korejskog teksta Tanak/a Tanka traka navigacije Automatski odabrane liste Prikaži \"Voleli\" listu Prikaži \"Preuzeta\" listu pjesama Prikaži \"Top\" listu Prikaži \"Prije spremljeni\" popis Prijavite se sa žetonom Ako pritisnite, prikazat će se žeton Pritisni opet da kopiras ili uredi Ovo je napredan način PRISTUPA LOGINu. Umjesto web portala, možete direktno unijeti ili ažurirati svoj token prijave ovdje. Na primjer, to može ubrzati prijavu na više uređaja. Molimo primijetite da će se bilo koji nevažeći formati tokena koje aplikacija ne može analizirati odbijati Sinkronizacija automatski sa nalogom Više sadržaja Općenito Proxy Promijeni zadanu knjižnicu čipa Postavi brze kombinacije Na temelju posljednje pjesme čuvane Jezik aplikacije Omogućite sličan sadržaj Automatski dodajte više sličnih pjesama kada se dostigne kraj redoslijeda %d%% Uvozite \"m3u\" popise Uvozite \"csv\" datoteke sa listama Dodavanje lokalnih pjesama na sinkronizirane/udaljene playliste nije podržano. Sve ostale kombinacije su važeće Automatsko preuzimanje na scroll (prijenos) ili na \"like Automatski preuzmi pjesme kada vam se svide Osetljivost poteza minijaturnog reproduktora %1$d%% Sigurno želite obrisati sve pjesme iz memorije? Sigurno želite obrisati sve pohranjene slike u cache? Sigurno želite obrisati sve preuzete datoteke? Onemogući Nije se prijavljen na YouTube Otavanje podržanih poveznica Nemogu otvoriti postavke aplikacije Obavijest o izdanjima [Croatian Sve vrijeme Prošle 24 sate Prošla sedmica Prošli mjesec Prošla godina Moja Top lista dužina Trajanje povijesti Informacija \", if the context requires it Pogledi Lajkovi Nenavoli [English to Croatian translation by translation software Prihvaćanje Pretplaćeni %d sekunda %d sekunde %d sekundi Pokretanje radija Trenutna reprodukcija Zatvori Sakrij sličicu reproduktora Zamijeni naslovnicu albuma s logotipom aplikacije u reproduktoru +%1$d sek. unaprijed -%1$d sek. unazad Progresivno traženje Ako je omogućeno, dodaje i do 5 dodatnih sek. pri svakom preskakanju traženja Onemogući učitavanje više prilikom ponavljanja svega Ne učitava automatski više pjesama i sličnog sadržaja kad je omogućen način ponavljanja svega Korisničko sučelje Privatnost i sigurnost Reproduktor i sadržaj Pohrana i podaci Sustav i o aplikaciji Ažuriranja Automatska provjera ažuriranja Ugradnja ================================================ FILE: app/src/main/res/values-hr/strings.xml ================================================ Playlista je sinkronizirana Poništi Tekst pjesama nije pronađen %d minuta %d minute %d minuta Sviđa mi se Sviđa mi se sve Ukloni sviđanje Ukloni sva sviđanja Centar Na strani Lijevo Desno Veličina ćelija mreže Malo Sadržaj Zadani jezik sadržaja Veliko Zadana zemlja sadržaja Visoka Obnovite svoj zadnji red kada se aplikacija ponovo pokrene Preskoči tišinu Normalizacija zvuka Ekvilajzer Spremište Predmemorija Predmemorija slika Pauziraj povijest slušanja Izbriši sva preuzimanja %s korišćeno Maksimalna veličina predmemorije slika Izbriši predmemoriju pjesama Povijest slušanja Izbriši povijest slušanja Jeste li sigurni da želite izbrisati svu povijest pretrage? Kada je ova opcija uključena, snimanje slika ekrana i pregled aplikacije u „Nedavni“ su isključeni. Sigurnosna kopija Obnovi Uvezena playlista Albumi Playliste %d odabran %d odabrana %d odabrano Povijest Ugođaj i žanrovi Račun Brzi odabiri Slušajte pjesme za generiranje brzih odabira Vaše YouTube playliste Slično kao Nova izdanja albuma Pretraži YouTube Music … Zbirka Sviđa mi se Preuzeto Videa Nema rezultata Pjesme zbirke će se ovdje prikazati Izvođači zbirke će se ovdje pojaviti Albumi zbirke će se ovdje pojaviti Vaše playliste će se ovdje pojaviti Iz vaše zbirke Druge verzije Pjesme koje mi se sviđaju Zaista želite izbrisati playlistu „%s“? Pokušaj ponovo Resetiraj Detalji Uredi Dodaj u popis Dodaj sve u zbirku Ukloni iz zbirke Ukloni sve iz zbirke Preuzmi Uvezi playlistu Dodaj playlistu Prikaži album Dijeli Ukloni iz reda Pretraži na internetu Sinkroniziraj Napredno Tempo i visina tona Datum dodavanja Ime Izvođač Godina Broj pjesama Dužina Vrijeme reprodukcije Prilagođeni redoslijed ID medija MIME vrsta Brzina prijenosa Glasnoća Glasnoća Veličina datoteke Kopirano u međuspremnik Uredi tekst Uredi pjesmu Izvođač pjesme ne može biti prazan. Odaberi playlistu Uredi playlistu Stvori playlistu Ime playliste Ime playliste ne može biti prazno. Uredi izvođača %d pjesme su već u vašoj playlisti %d pjesma %d pjesme %d pjesama %d izvođač %d izvođača %d izvođača %d playlista %d playliste %d playlista %d tjedan %d tjedna %d tjedana Spavanje Kraj pjesme Nijedan internetski prijenos nije dostupan Nema internet veze Istek vremena Nepoznata greška Miješanje uključeno Miješanje isključeno Modus ponavljanja je isključen Ponavljaj trenutačnu pjesmu Ponovi red Sve pjesme Pretražene pjesme Plejer glazbe Postavke Izgled Tema Uključi dinamičnu temu Tamna tema Uključeno Isključeno Slijedi sustav Potpuno crna Prilagodi navigacijske kartice Plejer Poravnanje teksta u plejeru Položaj teksta pjesama Stil klizača plejera Zadano Valovito Razno Zadana otvorena kartica Prijava Niste prijavljeni Zadano sustavom Uključi proxy Vrsta proxya URL proxyja Ponovo pokreni kako bi promjene stupile na snagu Plejer i zvuk Kvaliteta zvuka Automatski Niska Red Trajni red Automatski učitaj više pjesama Automatski dodaj više pjesama kada se dosegne kraj reda, ako je moguće Automatski preskoči do sljedeće pjesme kada dođe do greške Osigurajte vaše kontinuirano iskustvo reprodukcije Zaustavi glazbu nakon brisanja zadatka Predmemorija pjesama Maksimalna veličina predmemorije Neograničeno Izbriši predmemoriju slika Maksimalna veličina predmemorije pjesama Privatnost Jeste li sigurni da želite izbrisati svu povijest slušanja? Povijest pretrage Pauziraj povijest pretrage Izbriši povijest pretrage Isključi snimanje slike ekrana Uključi LrcLib dobavljača tekstova pjesama Uključi KuGou dobavljača tekstova pjesama Sakrij eksplicitan sadržaj Sigurnosna kopija i obnova Sigurnosna kopija je uspješno izrađena Nije bilo moguće stvoriti sigurnosnu kopiju Neuspjelo obnavljanje sigurnosne kopije Ugradnja Discord-u Odbaci Odjavi se Uključi prikaz aktivnosti Informacije Verzija aplikacije Izbriši modele prevođenja Početna Izvođači Pjesme Statistike Zaboravljeni favoriti Ovaj tjedan Istaknute playliste Pretraži Albumi Nastavite slušati Jučer Prošli tjedan Najslušanije pjesme Najslušaniji izvođači Najslušaniji albumi Danas Pretraži zbirku … Sve Pjesme Izvođači Playliste Playliste zajednice Zabilježeno Preuzete pjesme Playlista je prazna Promiješaj Zaista želite ukloniti sve pjesme playliste „%s“ iz spremišta „Preuzete pjesme“? Reproduciraj Reproduciraj sljedeću Radio Pokreni radio Dodaj u zbirku Preuzima se Ukloni preuzimanje Ukloni iz playliste Prikaži izvođača Ponovo dohvati Izbriši Ukloni iz povijesti Kodeki Frekvencija Nepoznato Naslov pjesme ne može biti prazan. Pjesma se već nalazi u vašoj playlisti Preskoči duplikate Pretraži tekstove pjesama Naslov pjesme Spremi Izvođač pjesme Ime izvođača Ime izvođača ne može biti prazno. Duplikati Dodaj svejedno Pjesma „%s“ je uklonjena iz playliste %d album %d albuma %d albuma %d mjesec %d mjeseca %d mjeseci %d godina %d godine %d godina Playlista je uvezena Metrolist koristi KizzyRPC biblioteku za postavljanje vašeg Discord status. Ovo uključuje korištenje Discord Gateway spajanja, što bi se moglo smatrati prekršajem Discord-ovog TOS-a. Međutim nema poznatih slučajeva gdje su korisnički računi zbog toga bili suspendirani. Korisite na vlastiti rizik.\n\nMetrolist će izdvojiti samo vaš žeton, sve drugo se sprema lokalno. Opcije Pregled Prijava nije uspjela Dostupna je nova verzija Modeli prevođenja Koristi prijavu za pregledavanje sadržaja Ovo može utjecati na sadržaj koji vidiš i, na primjer, prikazuje samo premium albume ako si prijavljen/a s Premium računom Prijavi se ================================================ FILE: app/src/main/res/values-hu/metrolist_strings.xml ================================================ Helyi Távoli Slágerlisták Vissza Album borító Top zene klippek Felkapott Hetek Hónapok Évek Folyamatos Kedvelt Letöltött Saját top Cache-lt Lejátszásilista szinkronizálása Szinkronizálás kikapcsolva Figyelem: Ez lehetővé teszi a YouTube Music-al való szinkronizálást. Ez később NEM megváltoztatható. Kép generálása Kérem várjon Mégse Dalszöveg megosztása Megosztás szövegként Megosztás képként Max kiválasztási határ Kiválasztottak megosztása Színek testreszabása Szöveg szín Másodlagos szöveg szín Háttér szín Eltávolítva a cache-ből Hivatkozás másolása Összes kijelölése Összes kedvelése Összes nem kedvelése Dátum frissítve Hivatkozás kimásolva a vágólapra Dalszöveg Már a lejátszásilistában: %d alkalom %d alkalmak Hasonló tartalom Lejátszó háttér stílus Téma követése Átmenet Új lejátszó design Homályosított Lejátszó gomb színek Alapbeállítás Bekapcsolja a húzással való váltást Húzza a számot balra a listához való hozzáadáshoz, vagy jobbra hogy következőnek azt játssza Dalszövegek váltása koppintásra Dalszövegek automata görgetése Japán dalszövegek latin betűsítése Koreai dalszövegek latin betűsítése Vékony Alsó navigációs sáv feliratok elrejtése Automata lejátszásilisták \"Kedvelt\" lejátszásilista megjelenítése \"Letöltött\" lejátszásilista megjelenítése \"Top\" lejátszásilista megjelenítése \"Gyorsítótárazott\" lejátszásilista megjelenítése Bejelentkezés tokennel Koppintson a token megjelenítéséhez Koppintson még egyszer a másoláshoz vagy szerkesztéshez Ez egy ÖSSZETETTEBB belépési módszer. A webes portálhoz képest itt közvetlen adhatja vagy frissítheti a belépési tokenjét. Például: ha egyszerre több eszközön akar belépni, ezzel felgyorsíthatja. Vegye azonban figyelembe, hogy minden token visszautasításra kerül, amit az app nem tud visszaigazolni Automata szinkronizálás a fiókkal Több tartalom Általános Proxy Alap könyvtár chip változtatása Gyors választások megadása Vegye alapul a legutolsó listázott számot App nyelve Hasonló tartalom engedélyezése Automatikusan hozzáad hasonló zenéket ha a lista a végéhez ért %d%% \"m3u\" lejátszásilista importálása \"csv\" lejátszásilista importálása Figyelem: A szinkronizált/távoli lejátszásilistához nem adhat hozzá helyi zenét. Minden más kombináció azonban működik A like-olt zenék automata letöltése Ha a szívvel bejelöli kedvenc zenéjét, automatikusan letöltésre kerül Mini lejátszó félrehúzás érzékenység %1$d%% Biztosan ki akarja üríteni az összes gyorsítótárazott zenéjét? Biztosan ki akarja üríteni az összes gyorsítótárazott képét? Biztosan ki akarja üríteni az összes letöltést? Kikapcsol Nincs bejelentkezve a YouTube-ba Támogatott hivatkozások megnyitása Nem nyitható meg az app beállítás Változásnapló Minden idők Elmúlt 24 órában Elmúlt héten Elmúlt hónapban Elmúlt évben A toplistám hossza Előzmény hossz Információ Leírás Megtekintések Tetszések Nemtetszések Feliratkozás Feliratkozva 1 másodperc %d másodperc Most játszva Bezárás A lejátszó gomb elrejtése Az album dizájnjának kicserélése az applikáció logójára %1$dmásodperc előre Progresszív keresés Amennyiben elfogadja, minden egyes átugrás után 5 másodperc összeadódik Új mini lejátszó dizájn Lejátszó beállításai Ne töltsön be automatikusan több dalt és hasonló tartalmat, amikor a minden újrajátszása mód be van kapcsolva Titoktartás és biztonság Lejátszó és tartalom Tároló és adat Rendszer és történet %1$d másodperc visszafelé Felület Feltöltve Feltöltve Rádió indítása Húzd el a zeneszámot a lejátszási listából való eltávolításhoz \"Feltöltött\" lejátszási lista mutatása Lejátszási lista borítóképének szerkesztése Összes szám letöltése offline lejátszáshoz Az összes letöltött szám törlése ebből a listából Letöltés folyamatban Lejátszási lista megosztása Lejátszási lista végleges törlése Lejátszási lista szinkronizálása a YouTube Music-kal Elsődleges szín Harmadlagos szín Ragyogó dalszöveg Ragyogó és ugráló animáció a dalszöveg aktuális sorára Jobb Dalszöveg bekapcsolása Használja a Jobb Dalszöveg szolgáltatót a szavanként szinkronizált dalszöveghez Újraszinkronizálás Lejátszás keverve Keveréskor először az eredeti lista/album dalait játssza le, majd a hasonló tartalmakat Wrapped kártya mutatása Megjegyzés: A lejátszási lista borítójának módosításához össze kell kapcsolnod a fiókodat egy telefonszámmal, és hitelesítened kell a YouTube Musicon. A kép kiválasztása után várj egy kicsit, amíg az új borító megjelenik a lejátszási listán. Választás a galériából Egyedi kép eltávolítása Proxy beállítása Proxy felhasználónév Proxy jelszó Hitelesítés bekapcsolása Részleteket használjon státusz helyett A dal címe jelenjen meg kiemelten az előadó neve helyett Cirill Latin betűs átírás Dalszöveg latin betűs átírása Kínai dalszövegek latin betűs átírása Orosz dalszövegek latin betűs átírása Ukrán dalszövegek latin betűs átírása Fehérorosz dalszövegek latin betűs átírása Kirgizisztáni dalszövegek latin betűs átírása Szerb dalszövegek latin betűs átírása Bolgár dalszövegek latin betűs átírása KÍSÉRLETI: Soronkénti nyelvfelismerés A cirill nyelv soronként lesz felismerve az egész dalszöveg helyett. Biztos vagy benne? Ez egy kísérleti funkció, változó megbízhatósággal.\n\nAlapértelmezésben a rendszer a teljes dal alapján határozza meg a nyelvet, ez az opció viszont soronkénti ellenőrzést tesz lehetővé. Ez hasznos a többnyelvű daloknál, DE a nyelvfelismerés nem mindig pontos (például, ha egy ukrán sor nem tartalmaz speciális ukrán karaktereket, a rendszer oroszként írhatja át).\n\nHa nem tapasztalsz problémát, javasolt kikapcsolva hagyni ezt az opciót. Aktuális dal latin betűs átírása Frissítések Frissítések automatikus keresése Frissítések értesítéseinek bekapcsolása Frissítés elérhető Alkalmazás frissítések Értesítés új verziókról Audio-kiszervezés engedélyezése Hardveres kiszervezés használata. Bár a kikapcsolása jobban merítheti az akkumulátort, segíthet, ha hibát észlelsz a lejátszásban vagy az effekteknél Google Cast Hangátvitel engedélyezése Chromecastra és egyéb Cast-eszközökre Macedón dalszövegek latin betűs átírása Integrációk Felhasználónév Jelszó Last.fm integráció Scrobbling engedélyezése \"Épp hallgatott\" állapot küldése Kedvelések küldése Dalok kedvencnek jelölése/törlése a Last.fm-en, ha a Metrolist-ben kedveled/nem kedveled őket Bejelentkezés… Scrobbling beállítása Ennél hosszabb számok scrobble-olása Scrobble késleltetés százalékban Scrobble késleltetés percekben Zenei videók elrejtése Zeneszám részleteinek megtekintése Az előadóról Több mutatása Kevesebb mutatása Előadói oldal Előadó leírása Feliratkozószám mutatása Havi hallgatók megjelenítése Hullámzó SimpMusic dalszövegek engedélyezése A szinkronizált dalszövegekhez a SimpMusic szolgáltató használata Számok csendes részeinek átugrása Csend azonnali átugrása A lejátszás felgyorsítása helyett ugorjon előre a csendes részeknél Keverés és ismétlés megjegyzése Keverés és ismétlés megjegyzése az app újraindításakor Zene szüneteltetése némításkor Dalszöveg eltolása Cím vagy előadó megváltoztatása Rádió indítása ez alapján Hozzáadás a lejátszási sor elejéhez Hozzáadás a lejátszási sor végéhez Mentés a könyvtárba Offline lejátszás engedélyezése Hozzáadás lejátszási listához Legújabb metaadat lekérése YouTube Music-ról Link megosztása Elem végleges eltávolítása Tempó és hangmagasság módosítása ================================================ FILE: app/src/main/res/values-hu/strings.xml ================================================ Kezdőlap Dalok Előadók Albumok Lejátszásilisták %d kijelölve %d kijelölve Előzmények Statisztikák Hangulatok és Műfajok Fiók Gyors választások Hallgasson zeneszámokat a gyors választások generálásához Újonnan megjelent albumok Ma Tegnap A héten Múlt héten Legtöbbet játszott dalok Legtöbbet játszott előadók Legtöbbet játszott albumok Keresés Keresés a YouTube Zenén… Keresés a könyvtárban… Könyvtár Kedvelt Letöltött Összes Dalok Videók Albumok Előadók Listák Közösségi lejátszási listák Kiemelt lejátszási listák Könyvjelzőzött Nincs találat Saját könyvtárból Kedvelt dalok Letöltött dalok A lejátszási lista üres Újra Rádió Keverés Visszaállítás Részletek Szerkesztés Rádió indítása Lejátszás Következő Listához ad Könyvtárhoz ad Eltávolítás a könyvtárból Letölt Letöltés alatt Letöltés eltávolítása Lejátszási lista importálása Hozzáadás lejátszási listához Ugrás az előadóhoz Ugrás az albumhoz Újrahív Megosztás Eltávolítás Eltávolítás az előzményekből Keresés online Szinkronizálás Haladó Hozzáadás dátuma Név Előadó Év Dal számláló Hossz Játszott idő Egyedi sorrend Média id MIME típus Kodekek Bitráta Mintavétel ráta Hangosság Hangerő Fájlméret Ismeretlen Vágólapra másolva Dalszöveg szerkesztése Dalszöveg keresése Dal szerkesztése Dal címe Dal előadói Dal cím nem lehet üres. Előadó neve nem lehet üres. Mentés Lejátszási lista kiválasztása Lejátszási lista szerkesztése Lejátszási lista létrehozása Lejátszási lista neve A lejátszási lista neve nem lehet üres. Előadó szerkesztése Előadó neve Az előadó neve nem lehet üres. %d dal %d dal %d előadó %d előadó %d album %d album %d lejátszási lista %d lejátszási lista %d hét %d hét %d hónap %d hónap %d év %d év Lejátszási lista importálva \"%s\" eltávolítva a lejátszási listából Lejátszási lista szinkronizálva Visszavonás Dalszöveg nem található Alvásidőzítő A dal vége 1 perc %d perc Nincs elérhető adatfolyam Nincs hálózati kapcsolat Időtúllépés Ismeretlen hiba Tetszik Tetszés eltávolítása Keverés BE Keverés KI Ismétlő mód KI Aktuális dal ismétlése Lista ismétlése Minden dal Keresett dalok Zenelejátszó Beállítások Megjelenés Dinamikus téma bekapcsolása Sötét téma Be Ki Rendszer téma követése OLED fekete Alapértelmezett megjelenő lap A navigációs lapok testreszabása Dalszöveg pozíciója Balra Középre Jobbra Tartalom Belépés Tartalom alapértelmezett nyelve Tartalom alapértelmezett országa Rendszer alapérték Proxy bekapcsolása Proxy típus Proxy hivatkozás Az alkalmazáshoz újraindítás szükséges Lejátszó és hang Hangminőség Automata Magas Gyenge Állandó lista Csend átugrása Hang normalizálása Hangszínszabályzó Tárhely Gyorsítótár Kép gyorsítótár Dal gyorsítótár Max. gyorsítótár méret Korlátlan Minden letöltés törlése Max. kép gyorsítótár méret Kép gyorsítótár ürítése Max. dal gyorsítótár méret Dal gyorsítótár ürítése %s felhasználva Adatvédelem A hallgatási előzmények szüneteltetése Hallgatási előzmények törlése Biztosan törli az összes hallgatási előzményt? Keresési előzmények szüneteltetése Keresési előzmények ürítése Biztosan törli az összes keresési előzményt? A KuGou dalszöveg szolgáltató engedélyezése Biztonsági mentés és visszaállítás Biztonsági mentés Visszaállítás Importált lejátszási lista A biztonsági mentés sikeresen létrehozva Sikertelen biztonsági mentés Sikertelen visszaállítás Névjegy Alkalmazásverzió Új verzió érhető el Fordítási modellek Fordítási modellek ürítése Elfelejtett kedvencek Zene folytatása Hasonló, mint A könyvtárában levő dalok itt jelennek meg A könyvtárában levő előadók itt jelennek meg A könyvtárában szereplő albumok itt jelennek meg A lejátszási listái itt jelennek meg Egyéb verziók Biztosan el akarja távolítani a \"%s\" lejátszási listán szereplő összes zenét a Letöltött Zenék tárhelyről? Biztosan törölni szeretné a \"%s\" lejátszási listát? Az összes eltávolítása a könyvtárból YouTube lejátszási listái Az összes hozzáadása a könyvtárhoz Eltávolítás lejátszási listáról Eltávolítás listáról Tempó és Hangszín Duplikáció Duplikációk átugrása Adja hozzá mindenképp A dal már megtalálható a lejátszási listában %d dal már megtalálható a lejátszási listájában Összes tetszik Minden tetszés eltávolítása Téma Lejátszó Lejátszó szöveg igazítás Oldalra Lejátszó sáv stílus Alapbeállítás Hernyó Egyéb Cella méret Kicsi Nagy Kijelentkezés Bejelentkezés Nincs bejelentkezve Sikertelen bejelentkezés Lista Utoljára használt lista visszaállítása app indításnál Automatikusan betölt még dalokat Ha lehetséges, automatikusan hozzáad dalokat a listához, amint a végéhez ért Hiba esetén átlép a következő dalra Biztosítva a folyamatos zene hallgatást Zene megállítása a feladat ürítéskor Hallgatás előzmény Keresési előzmények Belépés használata a tartalom böngészéséhez Ez hatással lehet az ajánlott tartalomra. Például megjelenhetnek prémium albumok, ha prémium fiókkal jelentkezik be Képernyőkép készítés kikapcsolása Ha ez be van kapcsolva, a képernyőkép készítés nem lehetséges, és az előzmények sem látszódnak. A LrcLib dalszöveg szolgáltató engedélyezése Felnőtt tartalom elrejtése Discord integráció Az Metrolist a KizzyRPC könyvtárat használja a Discord fiók státusz beállításhoz. Ez a Discord Gateway kapcsolatot használja, amely tekinthető a Discord felhasználási feltételek megszegésének is tágabb értelemben. Habár a múltban nem történt fiók tiltás emiatt, ettől függetlenül kérem használja saját felelősségre.\n\nAz Metrolist csak a token-jét fogja kinyerni, minden más helyben tárolt. Elrejtés Opciók Előnézet Rich Presence engedélyezése ================================================ FILE: app/src/main/res/values-in/metrolist_strings.xml ================================================ %d kali Konten serupa Online Tahun Bulan Mohon tunggu Membuat gambar Tangga Lagu Kembali Sampul album Video musik teratas Sedang Tren Minggu Berkelanjutan Disukai Diunduh Sinkronkan playlist Catatan: Ini memungkinkan sinkronisasi dengan YouTube Music. Pengaturan ini TIDAK dapat diubah di kemudian hari. Bagikan lirik Bagikan sebagai teks Bagikan sebagai gambar Batas maksimum pilihan Bagikan yang dipilih Sesuaikan warna Warna teks Warna teks sekunder Warna latar belakang Hapus dari cache Pilih semua Batal Login dengan token Ketuk untuk menampilkan token Ketuk lagi untuk menyalin atau mengubah Umum Bahasa aplikasi Belum login ke YouTube Catatan rilis Buka link yang didukung Tidak dapat membuka pengaturan aplikasi Sepanjang waktu Tayangan 24 jam terakhir Seminggu terakhir Sebulan terakhir Setahun terakhir Durasi riwayat Informasi Deskripsi Suka Tidak Suka Apakah Anda yakin ingin menghapus semua unduhan? Ini adalah metode login LANJUTAN. Sebagai alternatif dari portal web, Anda dapat langsung memasukkan atau memperbarui token login Anda di sini. Sebagai contoh, ini dapat mempercepat proses login di beberapa perangkat sekaligus. Perlu diperhatikan bahwa format token yang tidak valid dan gagal diproses oleh aplikasi tidak akan diterima Lokal Tanggal diperbarui Link disalin ke papan klip Lirik Sudah ada di playlist: Gaya latar belakang pemutar musik Teratas Saya Tersimpan di Cache Sinkronisasi dinonaktifkan Salin link Sukai semua Tidak sukai semua Ikuti tema Gradien Desain pemutar musik baru Buram Warna tombol pemutar musik Default Aktifkan gestur geser untuk mengganti lagu Geser lagu ke kiri untuk menambahkannya ke antrean, atau ke kanan untuk memutar selanjutnya Ganti lirik dengan ketukan Gulir lirik otomatis Romanisasi lirik Jepang Romanisasi lirik Korea Ramping Sembunyikan label bilah navigasi bawah Playlist otomatis Tampilkan playlist \"Disukai\" Tampilkan playlist \"Diunduh\" Tampilkan playlist \"Teratas\" Tampilkan playlist \"Tersimpan di Cache\" Sinkronkan otomatis dengan akun Konten lainnya Proksi Ubah chip pustaka default Atur pilihan cepat Berdasarkan lagu yang terakhir didengar Aktifkan konten serupa Otomatis menambahkan lagu serupa saat akhir antrean tercapai %d%% Impor playlist \"m3u\" Impor playlist \"csv\" Catatan: Menambahkan lagu lokal ke playlist yang tersinkron/online tidak didukung. Kombinasi lainnya tetap valid Unduh otomatis saat disukai Unduh lagu secara otomatis saat Anda menyukainya Sensitivitas geser pemutar mini %1$d%% Apakah Anda yakin ingin menghapus semua lagu yang tersimpan di cache? Apakah Anda yakin ingin menghapus semua gambar yang tersimpan di cache? Nonaktifkan Panjang daftar Teratas Saya Subscribe Disubscribe %d detik Desain pemutar musik mini baru Tutup +%1$d detik maju -%1$d detik mundur Nonaktifkan muat lebih banyak saat ulangi semua Jangan muat otomatis lagu tambahan dan konten serupa saat mode ulangi semua aktif Sedang Diputar Lompatan progresif Jika diaktifkan, menambahkan 5 detik ekstra secara bertahap pada setiap lompatan Ubah sampul playlist Sembunyikan Thumbnail Pemutar Musik Ganti sampul album di pemutar musik menjadi logo aplikasi Catatan: Akun Anda harus terhubung dengan nomor telepon dan telah terverifikasi di YouTube Music untuk mengubah sampul playlist. Aktifkan autentikasi Apakah Anda yakin? Fitur ini bersifat eksperimental dan tidak selalu dapat berfungsi dengan baik.\n\nSecara default, bahasa ditentukan dari keseluruhan lagu, tetapi dengan fitur ini aktif, akan ditentukan baris per baris. Ini akan memungkinkan lagu multi-bahasa berfungsi TETAPI bahasanya mungkin tidak selalu tepat (misalnya jika ada lirik Ukraina yang tidak mengandung huruf khusus Ukraina, mungkin akan diromanisasi sebagai Rusia).\n\nJika Anda tidak sedang mengalami masalah, disarankan untuk menonaktifkan fitur ini. Privasi & Keamanan Penyimpanan & Data Sistem & Tentang Memulai radio Setelah memilih gambar, harap tunggu sejenak hingga sampul yang baru muncul di playlist Anda. Atur proksi Username proksi Password proksi Romanisasi lirik Romanisasi lirik Rusia Romanisasi lirik Ukraina Romanisasi lirik Belarus Romanisasi lirik Kirgiz Romanisasi lirik Serbia Romanisasi lirik Bulgaria EKSPERIMENTAL: Deteksi bahasa baris per baris Bahasa Sirilik akan dideteksi baris per baris alih-alih keseluruhan lagu. Romanisasi lirik lagu saat ini Antarmuka Pemutar & Konten Pilih dari galeri Aktifkan offload Gunakan jalur audio offload untuk pemutaran audio. Menonaktifkan ini dapat meningkatkan penggunaan daya, tetapi berguna jika Anda sedang mengalami masalah dengan pemutaran audio atau pemrosesan Hapus gambar kustom Romanisasi Sirilik Diunggah Diunggah Tampilkan playlist \"Diunggah\" Gunakan detail alih-alih status Tampilkan judul lagu secara menonjol alih-alih nama artis Pembaruan Periksa pembaruan secara otomatis Aktifkan notifikasi pembaruan Pembaruan tersedia Romanisasi lirik Makedonia Integrasi Integrasi Last.fm Username Password Aktifkan scrobbling Kirim Now Playing Atur Scrobbling Scrobble lagu yang lebih lama dari Pembaruan aplikasi Notifikasi tentang versi terbaru Menit penundaan scrobble Persentase penundaan scrobble Geser lagu untuk menghapusnya dari playlist Unduh semua lagu untuk dapat diputar secara offline Hapus semua lagu yang telah diunduh dari playlist ini Pengunduhan sedang berlangsung Bagikan playlist ini kepada orang lain Hapus playlist ini secara permanen Sinkronkan playlist dengan YouTube Music Warna primer Warna tersier Aktifkan penyedia lirik Better Lyrics Lirik sinkron per suku kata untuk semua lagu, cocok untuk karaoke Sinkron Ulang Bagikan link ke item ini Hapus item ini secara permanen Ubah tempo dan tinggi nada lagu Sesuaikan ekualiser audio Aktifkan ikon dinamis Pemutar mini Pemutar mini berwarna hitam murni Tunggu dulu! Anda telah memilih batas ukuran cache yang lebih kecil dari yang saat ini digunakan oleh aplikasi (%1$s). Jika Anda melanjutkan, aplikasi mungkin akan menghapus beberapa %2$s yang tersimpan di cache untuk menyesuaikan dengan batas yang baru. Apakah Anda tetap ingin melanjutkan? Lanjutkan Gaya animasi lirik kata per kata Tidak ada Pudar Cahaya Geser Karaoke Apple Music Ukuran teks lirik Spasi baris lirik Sampul album untuk %s Anda telah mendengarkan album unik Album teratas Anda adalah Playlist personal Anda sudah siap 5 album teratas Anda Anda telah mendengarkan album ini selama %d menit %d menit Tidak ada data Artis teratas Anda sepanjang tahun ini %d menit Lagu teratas Anda sepanjang tahun ini Sampul album Artis teratas Anda sepanjang tahun ini adalah Gambar artis teratas Anda telah mendengarkan mereka selama %d menit Lagu yang paling sering Anda dengarkan adalah Anda telah mendengarkan selama %d menit Anda telah mendengarkan artis unik Anda telah mendengarkan lagu unik METROLIST saatnya melihat apa yang telah Anda dengarkan ayo mulai! Logo Metrolist 2025 WRAPPED ANDA SUDAH SIAP! Saatnya melihat apa yang Anda sukai sepanjang tahun ini. Terima kasih telah mendengarkan Terima kasih khusus kepada MO Agamy karena telah membuat Metrolist Tutup wrapped Wrapped %s Anda Buat playlist Playlist tersimpan Casting ke %s Progres %s%% Mendengarkan Metrolist Buka Gagal membuat gambar: %s Judul Disalin Artis Disalin Error saat memutar Gagal memproses URL proksi. Tampilkan kartu Wrapped Romanisasi lirik Mandarin Google Cast Aktifkan casting audio ke Chromecast dan perangkat Cast lainnya Kirim Likes/Unlikes Love/Unlove lagu di Last.fm saat di-Like/Unlike di Metrolist Sedang login… Sembunyikan video musik Lihat informasi lagu Ubah judul atau artis Buat stasiun radio berdasarkan item ini Tambahkan ke awal antrean Anda Tambahkan ke akhir antrean Anda Simpan ke pustaka Anda Sediakan untuk pemutaran offline Tambahkan ke salah satu playlist Anda Ambil metadata terbaru dari YouTube Music Aktifkan efek cahaya lirik Tambahkan animasi bercahaya dan efek memantul pada lirik yang aktif Acak playlist/album terlebih dahulu Saat mengacak, putar semua lagu dari playlist/album asli terlebih dahulu, kemudian konten serupa Bergelombang %d Profil Ekualiser Tidak ada profil ekualiser Impor Profil Dinonaktifkan %d pita frekuensi Hapus Profil Apakah Anda yakin ingin menghapus %1$s? Tindakan ini tidak dapat dibatalkan. Tidak dapat membaca file Gagal membuka file: %1$s Error Impor Jeda musik saat media dibisukan Aktifkan penyedia lirik SimpMusic Lyrics Lirik yang diambil secara otomatis dari Musixmatch dan Transkrip YouTube Sampul album Tidak ada lagu yang sedang diputar Ketuk untuk membuka Metrolist Sebelumnya Putar/Jeda Selanjutnya Suka Widget pemutar musik dengan kontrol pemutaran Widget musik berbentuk lingkaran dengan kontrol putar dan suka Ekualiser Sistem Ingat status acak dan ulang Ingat status acak dan ulang saat memulai ulang aplikasi Offset Lirik Tampilkan lebih banyak Tampilkan lebih sedikit Lewati bagian hening pada lagu secara otomatis Lewati keheningan seketika Tentang Halaman artis Tampilkan deskripsi artis Tampilkan jumlah subskriber Tampilkan jumlah pendengar bulanan Langsung melompati bagian hening alih-alih mempercepat pemutaran Acak persisten Pertahankan acak yang sedang aktif saat memulai lagu atau playlist baru Pemutaran gagal Error Gagal menerapkan profil ekualiser: %1$s Pangkas Sampul Album Paksa rasio aspek persegi dengan memangkas thumbnail video Jaga layar tetap menyala saat pemutar musik dibuka Menghubungkan ulang… Terputus Menghubungkan… Kesalahan koneksi Buat ruangan Buat sesi dan bagikan kode pada teman Gabung sesi Kode sesi Kamu adalah host Kamu adalah tamu Minta bergabung Lihat log Debug koneksi dan pesan Log koneksi Belum ada log Dengarkan musik bersama teman secara langsung. Buat sesi untuk menjadi host atau gabung sesi menggunakan kode. Catatan: Kamu bisa terputus jika kamu membuat sesi tanpa musik diputar lalu pindah ke aplikasi lain. Listen Together belum dikonfigurasi. Silakan atur URL server di Pengaturan → Integrasi → Listen Together. Saran terkirim ke host! %1$s ingin bergabung ke sesi Listen Together Notifikasi untuk acara Listen Together Sesi dibuat: %s Tidak dapat mengubah username saat berada di sesi Menunggu persetujuan dari host Kode sesi tidak valid Permintaan bergabung ditolak Gabung sesi yang ada Kode sesi Keluar sesi Gabung Buat Bergabung ke sesi %s… Membuat sesi… Hubungkan Putuskan Buat Gabung Setujui Tolak Hapus Salin Disalin ke papan klip Belum diatur Terhubung Dengar bersama URL Server Nama pengguna %1$s diminta %2$s Ruang hosting Di ruangan Menunggu permintaan Menunggu saran Sarankan ke host Keluarkan Bisu Bunyikan Host Anda Pengguna tersambung Masukkan nama pengguna Nama pengguna wajib diisi. Sinkron ulang Aplikasi terhenti Terjadi kesalahan yang tidak terduga. Silakan bagikan laporan crash untuk membantu kami memperbaiki masalah ini. Bagikan log Bagikan laporan crash Laporan Crash Metrolist Tutup Tidak ada log crash yang tersedia Dinamis Merah tua Merah muda Ungu Ungu tua Nila Biru Biru langit Sian Hijau kebiruan Hijau Hijau muda Hijau limau Kuning Kuning keemasan Oranye Oranye tua Cokelat Abu-abu Abu-abu kebiruan Kembali Mode hitam pekat Mode terang Mode gelap Mode sistem palet %1$s Tidak ada lagu yang diputar Ketuk untuk membuka Metrolist Pemutar Musik Meja putar Bersama Pilih server Server kustom Gunakan server kustom Setujui otomatis permintaan bergabung Setujui permintaan bergabung secara otomatis alih-alih meninjaunya secara manual Sinkronkan volume host Tamu mengikuti tingkat volume host Masukkan kode ruang Konfigurasikan server, nama pengguna, dan lainnya Salin kode Keluarkan orang ini dari sesi Blokir permanen Blokir permintaan bergabung orang ini dan sembunyikan saran mereka Transfer kepemilikan Jadikan orang ini host ruangan Kelola pengguna Pengguna yang diblokir %d pengguna diblokir Tidak ada pengguna yang diblokir Buka blokir Pengguna diblokir oleh host Terjemahan Lirik AI Menerjemahkan lirik... Lirik diterjemahkan Penyedia URL dasar Kunci API Model Mode terjemahan Bahasa target Kredensial API Terjemahan Transkripsi Kunci API diperlukan Kunci API wajib diisi Tidak ada lirik untuk diterjemahkan Lirik kosong Bahasa target wajib diisi Hasil terjemahan tidak terduga Terjadi kesalahan yang tidak diketahui Terjemahan gagal Putar semua Crossfade Crossfade antar lagu Durasi crossfade Nonaktifkan untuk album tanpa jeda Jangan crossfade jika album tanpa jeda Fitur Beta Crossfade adalah fitur baru dan mungkin memiliki bug. Jika Anda mengalami masalah, silakan laporkan.\n\nFitur ini menonaktifkan offload audio karena keterbatasan teknis. Dinonaktifkan karena Crossfade aktif Sembunyikan YouTube Shorts Listen Together di bilah atas Tampilkan Listen Together di bilah aplikasi atas alih-alih bilah navigasi Terjemahkan makna ke bahasa target Ubah pelafalan ke skrip target Dapatkan Kunci API Kunjungi [https://openrouter.ai](https://openrouter.ai) untuk model gratis dan berbayar Kunjungi [https://platform.openai.com/api-keys](https://platform.openai.com/api-keys) Kunjungi [https://console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) Kunjungi [https://aistudio.google.com/apikey](https://aistudio.google.com/apikey) Kunjungi [https://perplexity.ai/settings/api](https://perplexity.ai/settings/api) Kunjungi [https://console.x.ai](https://console.x.ai) Kunjungi [https://deepl.com/pro-api](https://deepl.com/pro-api) untuk kunci gratis dan berbayar Formalitas Default Lebih Formal Kurang Formal Aktifkan refresh rate tinggi Paksa layar untuk berjalan pada refresh rate tertinggi yang didukung (mis. 120Hz) Kenali Musik Ketuk untuk mengenali Mendengarkan… Memproses… Tidak ada kecocokan ditemukan Kesalahan pengenalan Coba lagi Riwayat Pengenalan Hapus riwayat pengenalan Apakah Anda yakin ingin menghapus semua riwayat pengenalan? Hapus dari riwayat Dengarkan ulang Putar di Metrolist Petakan Kolom CSV Baris pertama adalah header Kolom Nama Artis Kolom Judul Lagu Kolom URL YouTube (Opsional) Lanjutkan Mengimpor CSV Baru-baru Ini Dikonversi Kolom %d Status Online Idle Jangan Ganggu Tombol Tombol 1 Tombol 2 Login berhasil! Fitur ini menggunakan pustaka KizzyRPC untuk terhubung ke Gateway Discord dan mengatur status Rich Presence Anda. Meskipun tidak ada penangguhan akun yang diketahui dari penggunaan serupa, metode ini tidak didukung secara resmi oleh Discord dan dapat dianggap sebagai pelanggaran Ketentuan Layanan. Token Anda diekstrak secara lokal dan tidak pernah dikirim ke server pihak ketiga. Lanjutkan dengan pertimbangan Anda sendiri. Jenis aktivitas Bermain Mendengarkan Menonton Berkompetisi Variabel: {song_name}, {artist_name}, {album_name} Pratinjau Rich Presence Kehadiran Masuk dengan Discord untuk membagikan apa yang Anda dengarkan Bermain Metrolist Menonton Metrolist Berkompetisi di Metrolist Nama aktivitas Nama kustom untuk aktivitas (biarkan kosong untuk default) Mode lanjutan Tampilkan opsi kustomisasi tambahan untuk Rich Presence Aktifkan Cegah lagu yang sama di antrean Saat menambahkan lagu ke antrean, hapus kemunculannya sebelumnya jika lagu tersebut sudah ada dalam antrean Lanjutkan saat Bluetooth terhubung Pekat Romanisasikan lirik Hindi Romanisasikan lirik Punjabi Tampilkan lirik yang diromanisasi sebagai utama Skala tampilan Nyalakan ulang Perlu dinyalakan ulang Perubahan skala tampilan akan berlaku setelah aplikasi dinyalakan ulang. Ingin menyalakan ulang sekarang? Database lirik sinkron dari komunitas Mengambil lirik dari KuGou, platform musik populer asal Cina CATATAN: Lirik dari YouTube Music akan otomatis ditampilkan jika lirik lain tidak tersedia. Lirik dari YTM biasanya tidak sinkron. Aktifkan LyricsPlus Lirik sinkron dari berbagai sumber Pilih Penyedia Pilih penyedia lirik yang ingin diaktifkan Prioritas penyedia lirik Seret untuk mengubah urutan penyedia sesuai preferensi. Posisi lebih atas -> prioritas lebih tinggi. Catatan Perubahan Tidak ada catatan perubahan https://github.com/MetrolistGroup/Metrolist/releases Lihat di GitHub Versi saat ini Versi: %s Perbarui pengaturan Periksa pembaruan Sedang memeriksa pembaruan… Versi terbaru: %s Periksa pembaruan Sembunyikan catatan perubahan Lihat catatan perubahan Gagal memeriksa pembaruan: %s Jadikan default Timer tidur default diatur ke %d menit Aktifkan pengatur waktu tidur otomatis Mengaktifkan pengatur waktu tidur secara otomatis dengan nilai default berdasarkan waktu yang ditentukan Tetapkan hari dan waktu khusus saat pengatur waktu tidur harus aktif secara otomatis Ulangi Setiap hari Senin hingga Jumat Hari kerja / Akhir pekan Akhir pekan (Sab–Min) Kustom Waktu mulai Waktu selesai Senin Selasa Rabu Kamis Jumat Sabtu Minggu Berhenti di akhir lagu saat ini ketika pengatur waktu selesai Redam suara di menit terakhir Tersedia di Pengaturan > Konten diputar Gagal menyimpan episode Gagal menghapus episode Gagal berlangganan podcast Gagal berhenti berlangganan podcast Lihat Saluran Pengenal Musik Identifikasi lagu yang diputar di sekitar Anda langsung dari layar beranda Ketuk untuk mengidentifikasi lagu Mendengarkan… Mengidentifikasi… Tidak ada kecocokan. Coba lagi Pengenalan gagal Terjadi kesalahan. Silakan coba lagi Lagu tidak dikenal Artis tidak dikenal Identifikasi lagu Pengenalan Musik Menampilkan notifikasi saat mengidentifikasi lagu dari widget Merekam audio untuk mengidentifikasi lagu… Setujui otomatis saran lagu Secara otomatis menyetujui dan mengantrekan saran lagu dari tamu Mengimpor Daftar Putar Panggilan cepat Sematkan ke Panggilan cepat Lepas dari Panggilan cepat Acak Urutan Layar Beranda Mengacak ulang bagian layar beranda secara acak dengan prioritas berbobot Terdengar seperti %1$s Karena Anda mendengarkan %1$s Mirip dengan %1$s Berdasarkan %1$s Untuk penggemar %1$s Dari komunitas Simpan data perpustakaan? Apakah Anda ingin menyimpan daftar putar dan data perpustakaan Anda? Lagu yang diunduh akan tetap disimpan. Simpan Hapus Pengembang Utama Kolaborator Para Kolaborator Lisensi Publik Umum GNU v3.0 Perangkat lunak gratis dan sumber terbuka. Anda boleh menggunakan, mempelajari, berbagi, dan meningkatkannya. Server Discord Saluran Telegram Situs Web Instagram GitHub Lihat Repositori "%1$s - %2$s" Suka dengan yang saya lakukan? Belikan saya kopi Komunitas & Info METROLIST Ingin memutar lagu favorit mereka? Ya Proyek ini berpihak pada Palestina 🇵🇸 Podcast Lihat podcast Saluran Podcast Episode Terbaru Acara Anda Episode Baru Episode untuk Nanti Simpan untuk nanti Tambahkan ke daftar putar Episode untuk Nanti Anda Hapus dari tersimpan Simpan podcast ke perpustakaan %d episode Episode Profil Saluran Daftar putar otomatis Episode yang diunduh Tidak ada saluran yang diikuti Tidak ada episode yang diunduh %d saluran Pulihkan cadangan? Ini akan memulihkan data aplikasi Anda dari cadangan. Anda perlu masuk kembali setelah pemulihan. Akun berikut akan keluar: Pulihkan Memeriksa akun sebelumnya… Tidak ada akun yang ditemukan Unggah lagu Mengunggah… %1$d dari %2$d Unggahan selesai Unggahan gagal File terlalu besar (maks 300MB) Format tidak didukung. Gunakan mp3, m4a, wma, flac, atau ogg Hapus lagu yang diunggah Apakah Anda yakin ingin menghapus lagu yang diunggah ini? Tindakan ini tidak dapat dibatalkan. Lagu yang diunggah telah dihapus Gagal menghapus lagu yang diunggah Hapus lagu yang diunggah Apakah Anda yakin ingin menghapus %1$d lagu yang diunggah? Tindakan ini tidak dapat dibatalkan. %1$d lagu dihapus Menghapus… ================================================ FILE: app/src/main/res/values-in/strings.xml ================================================ Beranda Lagu Artis Album Daftar putar %d dipilih Riwayat Statistik Suasana Hati dan Genre Akun Pilihan cepat Dengarkan lagu untuk menghasilkan pilihan cepat Anda Album yang baru dirilis Hari ini Kemarin Minggu ini Minggu lalu Lagu yang paling sering diputar Artis yang paling sering diputar Album yang paling sering diputar Mencari Cari di YouTube Music… Cari di pustaka… Pustaka Disukai Diunduh Semua Lagu Video Album Artis Daftar putar Daftar putar komunitas Daftar putar unggulan Ditandai Tidak ada hasil yang ditemukan Dari pustaka Anda Lagu yang disukai Lagu yang diunduh Daftar putar kosong Coba lagi Radio Acak Atur ulang Rincian Ubah Mulai radio Putar Putar berikutnya Tambahkan ke antrean Tambahkan ke pustaka Hapus dari pustaka Unduh Mengunduh Hapus unduhan Impor daftar putar Tambahkan ke daftar putar Lihat artis Lihat album Ambil ulang Bagikan Hapus Hapus dari riwayat Cari online Sinkron Lanjutan Tanggal ditambahkan Nama Artis Tahun Jumlah lagu Durasi Waktu pemutaran Urutan khusus ID media Jenis MIME Kodek Laju bit Laju sampel Kelantangan Volume Ukuran berkas Tidak diketahui Disalin ke papan klip Ubah lirik Cari lirik Ubah lagu Judul lagu Artis lagu Judul lagu tidak boleh kosong. Artis lagu tidak boleh kosong. Simpan Pilih daftar putar Ubah daftar putar Buat daftar putar Nama daftar putar Nama daftar putar tidak boleh kosong. Ubah artis Nama artis Nama artis tidak boleh kosong. %d lagu %d artis %d album %d daftar putar %d minggu %d bulan %d tahun Daftar putar yang diimpor Menghapus \"%s\" dari daftar putar Daftar putar disinkronkan Batalkan Lirik tidak ditemukan Pengatur waktu tidur Akhir lagu %d menit Tidak ada aliran yang tersedia Tidak ada sambungan jaringan Waktu habis Terjadi kesalahan yang tidak diketahui Suka Hapus suka Acak diaktifkan Acak tidak diaktifkan Mode ulangi tidak diaktifkan Ulangi lagu saat ini Ulangi antrean Semua lagu Lagu yang dicari Pemutar Musik Pengaturan Tampilan Aktifkan tema dinamis Tema gelap Aktif Nonaktif Ikuti sistem Hitam murni Tab terbuka bawaan Sesuaikan tab navigasi Posisi teks lirik Kiri Tengah Kanan Konten Masuk Bahasa konten bawaan Negara konten bawaan Bawaan sistem Aktifkan proksi Jenis proksi URL proksi Mulai ulang untuk menerapkan perubahan Pemutar dan audio Kualitas audio Otomatis Tinggi Rendah Antrean yang berkelanjutan Lewati keheningan Normalisasi audio Ekualiser Penyimpanan Tembolok Tembolok Gambar Tembolok Lagu Batas maksimum ukuran tembolok Tak terbatas Bersihkan semua unduhan Batas maksimum ukuran tembolok gambar Bersihkan tembolok gambar Batas maksimum ukuran tembolok lagu Bersihkan tembolok lagu %s digunakan Privasi Jeda riwayat mendengarkan Bersihkan riwayat mendengarkan Apakah Anda yakin ingin menghapus semua riwayat mendengarkan? Jeda riwayat pencarian Bersihkan riwayat pencarian Apakah anda yakin ingin menghapus semua riwayat pencarian? Aktifkan penyedia lirik KuGou Cadangkan dan pulihkan Cadangkan Pulihkan Daftar putar yang diimpor Cadangan berhasil dibuat Tidak dapat membuat cadangan Gagal memulihkan cadangan Tentang Versi aplikasi Versi baru tersedia Model Penerjemahan Hapus model penerjemahan Favorit yang terlupakan Tetap mendengarkan Daftar putar Youtube Anda Serupa dengan Versi lainnya Tambahkan semua ke pustaka Apakah Anda benar-benar ingin menghapus daftar putar \"%s\"? Pustaka lagu akan tampil di sini Pustaka artis akan tampil di sini Pustaka album akan tampil di sini Daftar putar Anda akan tampil di sini Apakah Anda benar-benar ingin menghapus semua lagu daftar putar \"%s\" dari penyimpanan Lagu yang Diunduh? Hapus semua dari pustaka Hapus dari daftar putar Hapus dari antrean Pulihkan antrean terakhir Anda saat aplikasi dimulai Antrean Sembunyikan konten vulgar Lewati otomatis ke lagu berikutnya ketika terjadi kesalahan Samping Pilihan Gagal masuk Tambahkan saja Keluar Lainnya Kecepatan dan Nada Duplikat Lewati duplikat Lagu tersebut sudah ada di dalam daftar putar Anda %d lagu sudah ada di dalam daftar putar Anda Tema Sukai semua Hapus semua yang disukai Perataan teks pemutar Gaya penggeser pemutar Pemutar Bawaan Bergelombang Ukuran sel kisi Kecil Besar Memuat lebih banyak lagu secara otomatis Tidak masuk Otomatis menambahkan lebih banyak lagu ketika akhir antrean tercapai, jika memungkinkan Memastikan pengalaman pemutaran Anda yang berkesinambungan Hentikan musik saat menghapus tugas Riwayat mendengarkan Riwayat pencarian Nonaktifkan tangkapan layar Ketika pilihan ini diaktifkan, tangkapan layar dan tampilan aplikasi di Terkini dinonaktifkan. Aktifkan penyedia lirik LrcLib Integrasi Discord Metrolist menggunakan pustaka KizzyRPC untuk mengatur status akun Discord Anda. Hal ini melibatkan penggunaan sambungan Discord Gateway, yang dapat dianggap sebagai pelanggaran TOS Discord. Namun, tidak ada kasus yang diketahui tentang akun pengguna yang ditangguhkan karena alasan ini. Gunakan dengan risiko Anda sendiri.\n\nMetrolist hanya akan mengekstrak token Anda, dan yang lainnya disimpan secara lokal. Singkirkan Pratinjau Aktifkan Rich Presence Masuk untuk menjelajahi konten Hal ini dapat memengaruhi konten yang Anda lihat dan misalnya menampilkan album khusus premium jika Anda masuk dengan akun Premium Masuk ================================================ FILE: app/src/main/res/values-it/metrolist_strings.xml ================================================ Locale Remoto Classifiche Indietro Cover dell\'album I migliori video musicali Di tendenza Settimane Mesi Anni Continuo Piaciuti Scaricati I miei Top In cache Sincronizza playlist Sincronizzazione disabilitata Nota: questo attiva la sincronizzazione con YouTube Music. Tale azione NON è modificabile successivamente. Rimuovi dalla cache Copia link Seleziona tutti Mi piace tutto Togli Mi piace a tutto Data di aggiornamento Link copiato negli appunti Testo della canzone Già nella playlist: %d volta %d volte %d volte Contenuto simile Sfondo del lettore Segui il tema Gradiente Sfuma Colori dei pulsanti del riproduttore Predefiniti Attiva la possibilità di cambiare brano scorrendo sulla copertina Scorri il brano verso sinistra per aggiungerlo alla coda o verso destra per riprodurlo come successivo Cambia i testi con il tocco Sottile Barra inferiore di navigazione sottile Playlist automatiche Mostra la playlist dei brani piaciuti Mostra la playlist dei brani scaricati Mostra la playlist dei migliori brani Mostra la playlist dei brani in cache Accedi con il token Tocca per mostrare il token Tocca ancora per copiarlo o modificarlo Questo è un metodo AVANZATO per fare l\'accesso a YouTube Music. Come alternativa al portale web, puoi inserire o aggiornare direttamente il tuo token di accesso qui. Per esempio, questo può velocizzare l\'accesso su più dispositivi. Tieni presente che qualsiasi formato di token non valido che l\'app non riesce ad analizzare non sarà accettato Generale Proxy Cambia chip predefinito della libreria Imposta le scelte rapide Basato sull\'ultimo brano ascoltato Lingua dell\'app Attiva Contenuto simile Automaticamente aggiunge altri brani simili quando la fine della coda viene raggiunta %d%% Sei sicuro di voler eliminare tutti i brani in cache? Sei sicuro di voler eliminare tutti i brani scaricati? Disconnesso da YouTube Apri link supportati Impossibile aprire le impostazioni dell\'app Note di pubblicazione Da sempre Ultime 24 ore Ultima settimana Ultimo mese Ultimo anno Lunghezza della mia Top list Durata della cronologia Informazioni Descrizione Visualizzazioni Piaciuti Non mi piace 1 secondo %d secondi %d secondi Generazione dell\'immagine Prego attendere Annulla Condividi testo Condividi come testo Condividi come immagine Limite massimo di selezione Condividi selezionati Personalizza i colori Colore del testo Colore secondario del testo Colore dello sfondo Scarica automaticamente quando si mette Mi piace Scarica automaticamente i brani quando si mette Mi piace Scorri il testo automaticamente Importa una playlist in formato \"csv\" Nota: non è supportata l\'importazione di brani locali in una playlist sincronizzata o remota. Qualsiasi altra combinazione è valida Importa una playlist in formato \"m3u\" Altri contenuti Romanizza testo in giapponese Romanizza testo in coreano Sincronizza automaticamente con l\'account Nuovo design del lettore Disabilita Sei sicuro di voler cancellare tutte le immagini in cache? Sensibilità del gesto per cambiare brano nel mini lettore %1$d%% Iscriviti Iscritto Nuovo design del mini lettore In riproduzione %1$d secondi avanti %1$d secondi indietro Ricerca progressiva Se attivato, aggiunge 5 secondi in più per ogni salto Disabilita il caricamento di altri brani quando si ripetono tutti i brani Non caricare automaticamente altri brani e contenuti simili quando la modalità Ripeti tutto è attiva Chiudi Nascondi miniatura del lettore Sostituisci la copertina dell\'album con il logo dell\'app nel lettore Interfaccia Privacy e Sicurezza Lettore e Contenuto Archiviazione e dati Sistema e informazioni Avvio radio in corso Configura proxy Nome utente proxy Password proxy Attiva autentificazione Cirillico Romanizzazione Romanizzazione dei testi Romanizza testo in russo Romanizza testo in ucraino Romanizza testo in bielorusso Romanizza testo in kirghiso Romanizza testo in serbo Romanizza testo in bulgaro SPERIMENTALE: Determina la lingua linea per linea La lingua in cirillico verrà determinata linea per linea e non per tutto il brano. Sei sicuro? Romanizza attuale brano Questa è una funzione sperimentale.\n\nPer impostazione predefinita, la lingua è determinata dall\'intera canzone ma con questa opzione attiva, sarà invece determinata linea per riga. Questo permetterà a canzoni multilingua di essere romanizzate MA la lingua potrebbe non essere sempre corretta (ad esempio se c\'è un testo in ucraino che non contiene alcuna lettera specifica ucraina, potrebbe essere romanizzato come russo).\n\nSe non ne avete necessità, si consiglia di mantenere questa opzione disattivata. Modifica la copertina della playlist Nota: il tuo account deve essere collegato a un numero di telefono ed essere verificato su YouTube Music per modificare la copertina della playlist. Dopo aver selezionato un\'immagine, si deve attendere un momento affinché compaia la nuova immagine della tua playlist. Scegli dalla galleria Rimuovi immagine personalizzata Abilita trasferimento Utilizzare il percorso audio di scarico per la riproduzione audio. Disabilitare questo può aumentare l\'utilizzo dell\'energia ma può essere utile se si verificano problemi con la riproduzione audio o la post elaborazione Caricato Caricato Mostra la playlist dei brani caricati Usa i dettagli al posto dello stato Mostra il titolo della canzone prominente al posto dei nomi degli artisti Aggiornamenti Controlla automaticamente se ci sono aggiornamenti Attiva le notifiche sugli aggiornamenti Un aggiornamento è disponibile Aggiornamenti dell\'app Notifiche sulle nuove versioni Romanizza testo in macedone Attiva scrobbling Invia l\'ascolto attuale Configurazione dello scrobbling Invia brani più lunghi di Percentuale del ritardo sull\'invio Minuti di ritardo sull\'invio Integrazioni Nome utente Password Integrazione con Last.fm Scorri il brano per rimuoverlo dalla playlist Invia Mi piace/Non mi piace Ama/non amare brani su Last.fm quando (non) vengono apprezzati su Metrolist Romanizza testo in cinese Google Cast Attiva l\'invio dell\'audio a Chromecast e altri dispositivi Nascondi brani video Colore primario Risincronizza Visualizza le informazioni del brano Cambia il titolo o l\'artista Crea una stazione in base a questo brano Aggiungi in cima alla coda Aggiungi in fondo alla coda Salva nella tua libreria Rendi disponibile per l\'ascolto offline Aggiungi a una delle tue playlist Ottieni gli ultimi metadata da YouTube Music Condividi un collegamento con questo elemento Rimuovi definitivamente questo elemento Cambia il tempo e la tonalità del brano Regola l\'equalizzatore audio Attiva l\'icona dinamica Mini lettore Mini lettore nero puro Aspetta! Hai scelto un limite alla dimensione della cache più piccola di quella che l\'app sta usando attualmente (%1$s). Se continui, l\'app può rimuovere %2$s di cache per arrivare al nuovo limite. Procedere comunque? Continua Colore terziario Connessione in corso… Lo scaricamento è in corso Scarica tutti i brani per l\'ascolto offline Rimuovi tutti i brani scaricati da questa playlist Condividi questa playlist con altri Rimuovi definitivamente questa playlist Sincronizza la playlist con YouTube Music Attiva il fornitore di testi Better Lyrics Testi sincronizzati alla sillaba per ogni brano. Per karaoke Riproduzione casuale della playlist/album all\'inizio Durante la riproduzione casuale, riproduci prima tutte le canzoni della playlist/album originale e poi i contenuti simili Stile di animazione parola per parola Nessuno Dissolvenza Bagliore Diapositiva Karaoke Apple Music Dimensioni dei testi Interlinea dei testi I tuoi migliori 5 album Hai ascoltato questo album per %d minuti %d minuti Nessun dato I tuoi migliori artisti dell\'anno %d minuti I tuoi migliori brani di quest\'anno Copertina Il tuo migliore artista dell\'anno è Mostra la schermata Wrapped Copertina dell\'album di %s L\'hai ascoltato per album diversi Il tuo album preferito è La tua playlist personale è pronta Immagine del tuo artista preferito Li hai ascoltati per %d minuti La tua canzone più ascoltata è Hai ascoltato per %d minuti Hai ascoltato artisti diversi Hai ascoltato canzoni diverse METROLIST è l\'ora di vedere cosa hai ascoltato cominciamo! Logo di Metrolist 2025 IL TUO WRAPPED È PRONTO! È l\'ora di vedere cosa hai amato quest\'anno. Grazie per aver ascoltato Un ringraziamento speciale a MO Agami per aver creato Metrolist Chiudi wrapped Il tuo Wrapped %s Crea playlist Playlist salvata Trasmissione a %s in corso Progresso %s%% Ascoltando Metrolist Apri Impossibile creare l\'immagine: %s Titolo copiato Nome dell\'artista copiato Errore di riproduzione Impossibile eseguire il parsing dell\'URL proxy. Abilita l\'effetto luminoso per i testi Aggiungi un\'animazione luminosa e un effetto di rimbalzo ai testi attivi Interfaccia Equalizzatore %d profilo %d profili Equalizzatore Nessun profilo di equalizzazione Importa profilo Disabilitato %d banda %d bande %d bande Elimina profilo Sei sicuro di voler eliminare %1$s? Questa azione non può essere annullata. Impossibile leggere il file Impossibile aprire il file: %1$s Errore di importazione Ondulato Metti la musica in pausa quando il media è mutato Testi automaticamente trovati da Musixmatch e YouTube Transcript Attiva SimpMusic Lyrics Equalizzatore di sistema Copertina Nessun brano in riproduzione Premi per aprire Metrolist Precedente Play/Pausa Prossimo Mi piace Widget lettore musicale con controlli della riproduzione Widget musicale circolare con controlli di riproduzione e mi piace Ricorda le opzioni di ripetizione e di riproduzione casuale Ricorda le modalità di ripetizione e di riproduzione casuale quando l\'app viene riavviata Informazioni Mostra di più Mostra meno Pagina dell\'artista Mostra la descrizione dell\'artista Mostra il numero di iscritti Mostra gli ascolti mensili Avanza rapidamente attraverso le parti silenziose dei brani Salta immediatamente il silenzio Salta in avanti durante i momenti di silenzio invece di accelerare la riproduzione Tempismo dei testi Riproduzione casuale persistente Mantieni la riproduzione casuale attiva quando riproduci nuovi brani o playlist Errore Impossibile applicare il profilo EQ: %1$s Riproduzione non riuscita Ritaglia copertina dell\'album Forza le proporzioni quadrate ritagliando le miniature dei video Mantieni lo schermo acceso quando il lettore è espanso Ascolta insieme URL del server Nome utente Connesso Riconnessione… Disconnesso Connessione… Errore di connessione Crea una stanza Crea una stanza e condividi il codice con gli amici Entra nella stanza Codice della stanza Sei l\'ospitante Sei un ospite Richieste di ingresso Guarda i log Debug di connessione e messaggi Log di connessione Non ci sono ancora log Ascolta musica con gli amici in contemporanea. Crea una stanza per ospitare o entra in una stanza esistente con un codice. Nota: potresti perdere la connessione se crei una stanza mentre nessun brano viene riprodotto e poi passi a un\'altra app. Ascolta insieme non è configurato. Per favore, imposta l\'URL di un server in Impostazioni → Integrazioni → Ascolta insieme. %1$s ha richiesto %2$s Il suggerimento è stato inviato all\'ospitante! %1$s vuole entrare nella stanza Ascolta insieme Notifiche per Ascolta insieme Stanza creata: %s Impossibile modificare il nome utente mentre si è in una stanza In attesa di approvazione dell\'ospitante Codice stanza non valido Richiesta di ingresso negata Entra in una stanza esistente Codice stanza Abbandona stanza Entra Crea Ingresso nella stanza %s… Creazione della stanza… Connetti Disconnettiti Crea Entra Approva Rifiuta Rimuovi Copia Copiato negli appunti Non impostato Nella stanza Richieste in sospeso Suggerimenti in sospeso Suggerisci all\'ospitante Caccia Ospitante Tu Utenti connessi Inserisci nome utente Il nome utente è obbligatorio. Risincronizza Stanza ospitante Silenzia Riattiva audio Applicazione bloccata Si è verificato un errore inaspettato. Si prega di condividere il rapporto per aiutarci a risolvere il problema. Condividi i Log Condividi rapporto di errore Rapporto di Errore Metrolist Chiudi Nessun log di errore disponibile Dinamico Cremisi Rosa Viola Viola Intenso Indaco Blu Blu Cielo Ciano Verde petrolio Verde Verde Chiaro Lime Giallo Ambra Arancio Arancio Profondo Marrone Grigio Blu Grigio Retro Modalità Nero puro Modalità chiara Modalità scura Tavolozza %1$s Scegli un server Server personalizzato Usa un server personalizzato Approva automaticamente le richieste di adesione Approva automaticamente le richieste di adesione invece di esaminarle manualmente Sincronizza il volume dell\'ospitante Gli ospiti seguono il livello del volume dell\'ospite Copia codice Rimuovi questa persona dalla sessione Blocca Permanentemente Blocca le richieste di iscrizione di questa persona e nascondi i suoi suggerimenti Trasferisci Proprietà Rendi questa persona l\'ospite della stanza Gestisci Utente Utenti bloccati %d utente(i ) bloccati Nessun utente bloccato Sbloccare Utente bloccato dall\'host Modalità di sistema Nessun brano in riproduzione Premi per aprire Metrolist Riproduttore Musicale Giradischi Insieme Inserisci codice stanza Configura server, nome utente e altro Traduzione Testi con AI Traduzione del testo... Testo tradotto Fornitore URL Base Chiave API Modello Modalità di Traduzione Lingua di destinazione Credenziali API Traduzione Trascrizione Chiave API necessaria La lingua di destinazione è necessaria Risultato di traduzione inaspettato Si è verificato un errore sconosciuto Traduzione fallita È necessaria una chiave API Nessun testo da tradurre Il testo è vuoto Riproduci tutto Riconoscimento Musica Colonna Nome Artista Mappa colonne CSV Col %d Riprova Cronologia dei Riconoscimenti Colonna Titolo del Brano Convertito di recente Importazione di CSV Colonna URL di YouTube (Facoltativo) Riascolta Sei sicuro di voler cancellare tutta la cronologia dei riconoscimenti? Nessuna corrispondenza trovata Elimina dalla cronologia Elaborazione… Cancella cronologia dei riconoscimenti Errore di riconoscimento Forza il display a funzionare alla frequenza di aggiornamento più alta supportata (ad esempio 120 Hz) La prima riga è l\'intestazione Tocca per riconoscere Abilita frequenza di aggiornamento elevata Riproduci su Metrolist In ascolto… Continua Attiva Dissolvenza incrociata Dissolvenza incrociata tra i brani Durata della dissolvenza incrociata Disabilita per album senza interruzioni Non eseguire la dissolvenza incrociata se l\'album è senza pause Funzionalità Beta La dissolvenza incrociata è una nuova funzionalità e potrebbe contenere bug. Se riscontri problemi, segnalali.\n\nQuesta funzionalità disabilita lo scaricamento dell\'audio a causa di limitazioni tecniche. Disabilitato perché Crossfade è attivo Nascondi gli Shorts di YouTube Ascolta Insieme nella barra in alto Mostra Ascolta insieme nella barra dell\'app in alto anziché nella barra di navigazione Traduci il significato nella lingua di destinazione Converti la pronuncia nella lingua di destinazione Ottieni chiavi API Visita https://openrouter.ai per i modelli gratuiti e a pagamento Visita https://platform.openai.com/api-keys Visita https://console.anthropic.com/settings/keys Visita https://aistudio.google.com/apikey Visita https://perplexity.ai/settings/api Visita https://console.x.ai Visita https://deepl.com/pro-api per le chiavi gratuite e a pagamento Formalità Predefinito Più formale Meno formale Stato Online Inattivo Non disturbare Pulsanti Pulsante 1 Pulsante 2 Accesso eseguito con successo! Tipo di attività Previeni tracce duplicate in coda Quando una traccia viene aggiunta nella coda, rimuovila dalle posizioni precedenti se è già presente Mostra opzioni di personalizzazione aggiuntive per Rich Presence Modalità avanzata Nome personalizzato per l\'attività (lascia vuoto per predefinito) Nome dell\'attività Competendo su Metrolist Guardando video su Metrolist Riproducendo musica su Metrolist Accedi con Discord per condividere ciò che stai ascoltando Presenza Anteprima della Rich Presence Variabili: {song_name} per il nome del brano, {artist_name} per il nome dell\'artista, {album_name} per il nome dell\'album Competere Guardando Ascoltando Riproducendo Questa funzione utilizza la libreria KizzyRPC per connettersi al gateway di Discord e impostare il tuo stato di Rich Presence. Sebbene non si siano verificate sospensioni di account note a causa di un utilizzo simile, questo metodo non è ufficialmente supportato da Discord e potrebbe essere considerato una violazione dei Termini di Servizio. Il tuo token viene estratto localmente e non viene mai inviato a server di terze parti. Procedi a tua discrezione. Solido Riprendi dalla connessione Bluetooth Romanizza testo in Hindi Romanizza testo in Punjabi Mostra i testi romanizzati come principali Densità dello schermo Riavvia Il riavvio è necessario Il cambio della densità dello schermo si applicherà dopo aver riavviato l\'app. Vuoi riavviarla ora? Database di testi sincronizzati gestito dalla comunità Ottiene i testi da KuGou, una piattaforma cinese popolare di musica NOTA: i testi tratti da YouTube Music saranno mostrati automaticamente quando altri testi non sono disponibili. I testi di YouTube Music solitamente non sono sincronizzati. Attiva LyricsPlus Testi sincronizzati da più fonti Selezione del fornitore Scegli quali fornitori di testi utilizzare Priorità dei fornitori di testi Controlla aggiornamenti Nascondi le novità Mostra le novità Errore nel controllare gli aggiornamenti: %s Imposta come predefinito Spegnimento automatico impostato come predefinito a %d min Trovato in Impostazioni > Contenuti riproduzioni Errore nel salvare l\'episodio Errore nel rimuovere l\'episodio Errore nel seguire il podcast Trascina per riordinare i fornitori per preferenza. Alta posizione = alta priorità. Novità Nessuna novità disponibile https://github.com/MetrolistGroup/Metrolist/releases Mostra su GitHub Versione attuale Versione: %s Impostazioni di aggiornamento Controlla aggiornamenti Controllo di aggiornamenti… Ultima: %s Impossibile annullare l\'iscrizione al podcast Approvazione automatica dei brani suggeriti Approva automaticamente e inserisci in coda le richieste di brani da parte degli ospiti Importazione Playlist Randomizza l\'ordine della Schermata Iniziale Riordina in modo casuale le sezioni della schermata iniziale con priorità ponderate Sembra %1$s Perché ascolti %1$s Simile a %1$s Basato su %1$s Per i fan di %1$s Dalla comunità Vuoi mantenere i dati della libreria? Vuoi conservare le tue playlist e i dati della libreria? I brani scaricati verranno conservati comunque. Mantieni Pulisci Sviluppatore principale Collaboratore Collaboratori Licenza GNU General Public License v3.0 Software gratuito e open source. Puoi usarlo, studiarlo, condividerlo e migliorarlo. Server di Discord Canale di Telegram Sito Web Instagram GitHub Visualizza Archivio %1$s • %2$s Ti piace quello che faccio? Offrimi un caffè Comunità e Informazioni METROLIST Vuoi riprodurre il loro brano preferito? Questo progetto sta dalla parte della Palestina 🇵🇸 Podcast Mostra podcast Canali Podcast Ultimi Episodi Nuovi Episodi Episodi per dopo Salva per dopo Aggiungi alla playlist Episodi per Dopo Rimuovi dai salvati Salva podcast nella libreria %d episodio %d episodi %d episodi Vuoi ripristinare il backup? Ciò ripristinerà i dati dell\'app dal backup. Sarà necessario accedere nuovamente dopo il ripristino. Verrà disconnesso il seguente account: Ripristinare Verifica account precedente… Nessun account trovato Riconoscitore Musicale Identifica i brani che ti circondano direttamente dalla schermata iniziale Tocca per identificare il brano In ascolto… Identificazione… Nessuna corrispondenza trovata. Prova di nuovo Riconoscimento fallito Si è verificato un errore. Prova di nuovo Brano sconosciuto Artista sconosciuto Identificare brano Riconoscimento Musicale Mostra una notifica mentre stai identificando un brano dal widget Registrazione dell\'audio per identificare il brano… Episodi Canali Playlist automatica Episodi scaricati Nessun canale sottoscritto Nessun episodio scaricato %d canale %d canali %d canali Selezione rapida Fissa nella Selezione rapida Sblocca dalla Composizione rapida I Tuoi Spettacoli Vedi canale Profili Ripeti Giornaliero Da Lunedì a Venerdì Giorni feriali / Fine settimana Fine settimana (Sab–Dom) Personalizzato Orario di inizio Orario di fine Lunedì Martedì Mercoledì Giovedì Venerdì Sabato Domenica Interrompi al termine del brano corrente quando il timer scade Dissolvenza nell\'ultimo minuto Carica brani Caricamento… %1$d di %2$d Caricamento completato Caricamento non riuscito File troppo grande (max 300MB) Formato non supportato. Usa mp3, m4a, wma, flac o ogg Elimina brano caricato Sei sicuro di voler eliminare questo brano caricato? Questa azione non può essere annullata. Brano caricato eliminato Impossibile eliminare il brano caricato Elimina brani caricati Sei sicuro di voler eliminare %1$d brani caricati? Questa azione non può essere annullata. Elimina %1$d brani Eliminazione… Attiva timer per lo spegnimento automatico Attiva lo spegnimento automatico con un valore temporale personalizzato Imposta um giorno e un orario personalizzati per quando lo spegnimento automatico si deve attivare ================================================ FILE: app/src/main/res/values-it/strings.xml ================================================ Home Brani Artisti Album Playlist %d selezionato %d selezionati %d selezionati Cronologia Statistiche Umori e generi Account Scelte rapide Ascolta alcuni brani per generare le tue scelte rapide Nuovi album in uscita Oggi Ieri Questa settimana Settimana scorsa Brani più ascoltati Artisti più ascoltati Album più ascoltati Cerca Cerca su YouTube Music… Cerca nella libreria… Libreria Piaciuti Scaricati Tutto Brani Video Album Artisti Playlist Playlist della comunità Playlist in evidenza Aggiunto ai segnalibri Nessun risultato trovato Dalla tua libreria Brani piaciuti Brani scaricati La playlist è vuota Riprova Radio Casuale Ricomincia Dettagli Modifica Avvia radio Riproduci Riproduci come successiva Metti in coda Aggiungi a libreria Rimuovi da libreria Scarica In scaricamento Rimuovi download Importa playlist Aggiungi a playlist Mostra artista Mostra album Aggiorna Condividi Elimina Rimuovi da cronologia Cerca online Sincronizza Avanzate Data di aggiunta Nome Artista Anno Numero brani Durata Numero di riproduzioni Ordine personalizzato ID media Tipo MIME Codec Bitrate Frequenza di campionamento Rumorosità Volume Dimensioni Sconosciuto Copiato negli appunti Modifica testo Cerca testo Modifica brano Titolo del brano Artisti del brano Il titolo del brano non può essere vuoto. L\'artista del brano non può essere vuoto. Salva Scegli una playlist Modifica playlist Crea playlist Nome della playlist Il nome della playlist non può essere vuoto. Modifica artista Nome dell\'artista Il nome dell\'artista non può essere vuoto. %d brano %d brani %d brani %d artista %d artisti %d artisti %d album %d albums %d album %d playlist %d playlist %d playlist %d settimana %d settimane %d settimane %d mese %d mesi %d mesi %d anno %d anni %d anni Playlist importata Rimosso \"%s\" da playlist Playlist sincronizzata Annulla Testo non trovato Timer per il sonno Fine del brano 1 minuto %d minuti %d minuti Stream non disponibile Nessuna connessione di rete Tempo scaduto Errore sconosciuto Mi piace Rimuovi mi piace Riproduzione casuale attivata Riproduzione casuale disattivata Modalità ripetizione disattivata Ripeti brano corrente Ripeti coda Tutti i brani Brani cercati Riproduttore Impostazioni Aspetto Abilita il tema dinamico Tema scuro Attivato Disattivato Segui sistema Nero Scheda principale predefinita Personalizza le schede di navigazione Allineamento del testo del riproduttore Posizione del testo dei brani Laterale Sinistra Centro Destra Contenuti Accesso Lingua predefinita dei contenuti Paese predefinito dei contenuti Predefinito di sistema Attiva proxy Tipologia del proxy URL proxy Riavvia l\'app per applicare le modifiche Riproduttore e audio Qualità dell\'audio Automatica Alta Bassa Coda persistente Salta il silenzio Normalizzazione dell\'audio Equalizzatore Archiviazione Cache Cache delle immagini Cache dei brani Dimensione massima della cache Illimitata Cancella tutti i download Grandezza massima della cache delle immagini Pulisci la cache delle immagini Grandezza massima della cache dei brani Pulisci la cache dei brani %s usati Privacy Sospendi la cronologia degli ascolti Cancella la cronologia degli ascolti Sei sicuro di voler cancellare la cronologia degli ascolti? Sospendi la cronologia delle ricerche Pulisci la cronologia delle ricerche Sei sicuro di voler cancellare la cronologia delle ricerche? Attiva il fornitore di testi KuGou Backup e ripristino Fai un backup Ripristina Playlist importata Backup creato con successo Impossibile eseguire il backup Impossibile eseguire il ripristino dal backup Informazioni Versione dell\'app Nuova versione disponibile Modelli di traduzione Cancella i modelli di traduzione Ondulato Rimuovi tutti i mi piace Gli album della libreria verranno visualizzati qui Le tue playlist verranno visualizzate qui Rimuovi dalla coda Stile slider del player Continua ad ascoltare Le tue playlist di YouTube La canzone %d è già nella tua playlist La canzone è già nella tua playlist Vuoi davvero rimuovere la playlist %s? I brani della libreria verranno visualizzati qui Rimuovi tutto da libreria Duplicati Default Altre versioni Tempo e Tono Aggiungi tutto a libreria Rimuovi da playlist Tema Lettore Preferiti dimenticati Simile a Aggiungere comunque Vuoi davvero rimuovere tutti i brani della playlist \"%s\" dalla memoria dei Brani Scaricati? Salta duplicati Gli artisti della libreria verranno visualizzati qui Mi piace a tutto Non autenticato Piccolo Grande Altro Coda Ripristina l\'ultima coda all\'apertura dell\'app Carica automaticamente più canzoni Salta alla prossima canzone quando si verifica un errore Aggiungi automaticamente canzoni quando si raggiunge la fine della coda, se possibile Attiva il fornitore di testi LcrLib Dimensione delle celle della griglia Garantisci la tua esperienza di riproduzione continua Interrompi la musica quando l\'attività è interrotta Cronologia ascolti Cronologia ricerche Disabilita screenshot Quando questa opzione è attiva, gli screenshot e la visualizzazione dell\'app nei Recenti sono disabilitati. Nascondi contenuti espliciti Integrazione Discord Metrolist usa la libreria KizzyRPC per impostare lo stato del tuo account Discord. Ciò comporta l\'uso della connessione Discord Gateway, che può essere considerata una violazione dei TOS di Discord. Tuttavia, non ci sono casi noti di account utente sospesi per questo motivo. Usalo a tuo rischio e pericolo.\n\nMetrolist estrarrà solo il tuo token e tutto il resto verrà archiviato localmente. Opzioni Usa il login per navigare nei contenuti Anteprima Login fallito Esci Ciò può influenzare il contenuto che vedi e ad esempio mostra album solo premium se hai effettuato l\'accesso con un account Premium Abilita Rich Presence Ignorare Accedi ================================================ FILE: app/src/main/res/values-iw/metrolist_strings.xml ================================================ שבועות חודשים צבע רקע מקומי מרוחק טבלאות אחורה כיסוי אלבום סרטוני מוזיקה מובילים טרנדינג שנים מתמשך אהוב הורד הנשמעים ביותר שלי נשמר סנכרן את הפלייליסט סינכרון מכובה הערה: זה מאפשר סנכרון עם YouTube Music. לא ניתן לשנות זאת מאוחר יותר. יצירת תמונה אנא המתן ביטול שתף את מילות שיר שתף כטקסט שתף כתמונה מגבלת בחירה מקסימלית שתף את הנבחרים התאמה אישית של צבעים צבע טקסט צבע טקסט משני סנכרון רשימת ההשמעה עם YouTube Music תיאור מילות השיר סגור הבא מחיקת רשימת ההשמעה הזו באופן סופי העתקת הקישור בחירת הכל לייק להכל דיסלייק להכל מחיקת רשימת ההשמעה הזו סופית הקישור הועתק ללוח הפעלת הרדיו מתנגן כעת הסתרת צלמית הנגן החלפת תמונת האלבום בסמל היישום בנגן חיתוך תמונת האלבום כפיית יחס אורך-רוחב ריבועי ע\"י חיתוך צלמיות הוידאו כבר נכללים ברשימת ההשמעה: פעם אחת %d פעמים %d פעמים +%1$d שניות קדימה -%1$d שניות אחורה סריקה מתקדמת אם מופעלת, מוסיפה עד 5 שניות נוספות בהדרגה בכל דילוג של הסריקה ברירת-המחדל צבע ראשי צבע שלישוני גלי הפעלת החלקה למעבר שיר החלקה על שיר לשמאל להוספתו לתור או לימין להשמעתו בהמשך החלקה על שיר להסרתו מרשימת ההשמעה שינוי מילות השיר בנגיעה גלילה אוטומטית של מילות השיר הפעלת אפקט זוהר למילות השיר הוספת הנפשות זוהר וקפיצה למילות השיר הפעילות הפעלת מילות שיר משופרות שימוש בספק מילות שירים משופר לסנכרון מילה-למילה של מילות השיר הפעלת מילות שיר SimpMusic שימוש בספק מילות-שיר SimpMusic למילות שיר מסונכרנות תוכן דומה סגנון הרקע של הנגן מלא עפ\"י ערכת הנושא הדרגה עיצוב נגן חדש עיצוב נגן זעיר חדש טשטוש צבעי פקדי הנגן סנכרון מחדש צר סרגל ניווט תחתון צר רשימות השמעה אוטומטיות הצגת רשימת השמעה אהובה הצגת רשימת השמעה שהורדה הצגת רשימת השמעה מובילה הצגת רשימץ השמעה שבמטמון הצגת רשימת השמעה שהועלתה רשימת השמעה/אלבום תחילה בנגינה בסדר אקראי בעת השמעה בסדר אקראי, השמעה תחילה של כל השירים מרשימת ההשמעה/האלבום המקוריים, ואח\"כ תוכן דומה מניעת רצועות כפולות בתור בעת הוספת רצועה לתור ההשמעה, הסרתה ממקומה הקודם אם היא כבר בתור הועלו הועלו הפעלה הסרה מהמטמון אודות ================================================ FILE: app/src/main/res/values-iw/strings.xml ================================================ שירים היסטוריה בית אמנים אלבומים פלייליסטים %d נבחר %d נבחרו %d נבחרו סטטיסטיקות מצב רוח וז\'אנרים חשבון בחירות מהירות האזינו לשירים כדי ליצור את הבחירות המהירות שלכם מועדפים שנשכחו המשך האזנה פלייליסטים ביוטיוב שלך דומה ל אלבומים חדשים היום אתמול השבוע בשבוע האחרון השירים הכי מושמעים האמנים הכי מושמעים אלבומים הכי מושמעים חיפוש חפש ביוטיוב מוזיקה… חפש בספרייה… ספרייה אהבתי ירדו הכל שירים סרטונים אלבומים אמנים פלייליסטים פלייליסטים קהילתיים פלייליסטים נבחרים סומן כמועדף לא נמצאו תוצאות שירי הספרייה יופיעו כאן אמני הספרייה יופיעו כאן אלבומי הספרייה יופיעו כאן הפלייליסטים שלך יופיעו כאן מהספרייה שלך גירסאות אחרות שירים שאהבתי שירים שהורדתי הפלייליסט ריק האם אתה באמת רוצה להסיר את כל שירי הפלייליסט \"%s\" מאחסון השירים שהורדו? האם אתה באמת רוצה למחוק את רשימת ההשמעה \"%s\"? נסה שוב רדיו ערבוב איפוס פרטים עריכה התחל רדיו נגן הפעל הבא הוסף לתור הוסף לספרייה הוסף הכל לספרייה הסר מהספרייה הסר הכל מהספרייה הורד מוריד הסר הורדה ייבוא פלייליסט הוסף לפלייליסט הצג אמן הצג אלבום אחזור שיתוף מחיקה הסר מההיסטוריה הסר מהפלייליסט הסר מהתור חיפוש באינטרנט סינכרון מתקדם קצב וגובה צליל תאריך הוספה שם אמן שנה ספירת שירים משך זמן ניגון הזמנה בהתאמה אישית מזהה מדיה סוג MIME קודקים קצב סיביות קצב דגימה עוצמת קול עוצמת שמע גודל קובץ לא ידוע הועתק ללוח עריכת מילות שיר חיפוש מילות שיר ערוך שיר כותרת שיר אמני שיר שם השיר לא יכול להיות ריק. לא ניתן להשאיר את שדה אמן השיר ריק. שמור בחר פלייליסט ערוך פלייליסט צור פלייליסט שם פלייליסט שם הפלייליסט לא יכול להיות ריק. ערוך אמן שם אמן שם האמן לא יכול להיות ריק. כפילויות דילוג על כפילויות הוסף בכל זאת השיר כבר נמצא ברשימת ההשמעה שלך %d שירים כבר נמצאים ברשימת ההשמעה שלך שיר %d %d שירים %d שירים אמן %d %d אמנים %d אמנים אלבום %d %d אלבומים %d אלבומים פלייליסט %d %d פלייליסטים %d פלייליסטים שבוע %d %d שבועות %d שבועות חודש %d %d חודשים %d חודשים סוף השיר כל השירים ‪שירים שחיפשת שחור שנה %d %d שנים %d שנים פלייליסט יובא \"%s\" הוסר מרשימת ההשמעה פלייסליט סונכרן בטל מילות השיר לא נמצאו טיימר שינה דקה 1 %d דקות %d דקות אין סטרים זמין אין חיבור אינטרנט פסק זמן שגיאה לא ידועה לייק הסר לייק הסר את כל הלייקים ערבוב מופעל ערבוב כבוי מצב חזרה כבוי חזרה על השיר הנוכחי חזרה על התור שירים שחיפשת הגדרות נראות עיצוב הפעלת עיצוב דינמית עיצוב כהה פועל כבוי כמו המערכת התאמה אישית של כרטיסיות ניווט נגן צדדי שמאל מרכז ימין ‎סגנון מחוון הנגן ברירת מחדל יישור טקסט של הנגן מיקום מילות השיר מתפתל שונות ‌כרטיסיית פתיחה כברירת מחדל גודל תא הרשת קטן גדול תוכן התנתקות התחברות התחברות לא מחובר ההתחברות נכשלה שפת תוכן ברירת מחדל מדינת תוכן ברירת מחדל ברירת מחדל מערכת הפעלת פרוקסי סוג פרוקסי כתובת פרוקסי הפעל מחדש כדי שיכנס לתוקף נגן ושמע איכות שמע אוטומטי גבוה נמוך תור תור קבוע שחזר את התור האחרון שלך כאשר האפליקציה נדלקת טעינה אוטומטית של שירים נוספים הוסף אוטומטית שירים נוספים כאשר מגיעים לסוף התור, אם אפשר דילוג על שקט נרמול שמע דילוג אוטומטי לשיר הבא כאשר מתרחשת שגיאה הבטיחו את חוויית ההשמעה הרציפה שלכם עצירת מוזיקה בעת ניקוי המשימה אקלוייזר אחסון מטמון מטמון תמונה מטמון שירים גודל מטמון מקסימלי ללא הגבלה נקה את כל ההורדות גודל מטמון מקסימלי של תמונה נקה את מטמון התמונה גודל מטמון מקסימלי של שירים נקה את מטמון השירים %s בשימוש פרטיות היסטוריית האזנה השהיית היסטוריית ההאזנה נקה את היסטוריית ההאזנה האם אתה בטוח שברצונך לנקות את כל היסטוריית ההאזנה? היסטוריית חיפוש השהיית היסטוריית החיפוש …נקה את היסטוריית החיפוש האם אתה בטוח שברצונך למחוק את כל היסטוריית החיפוש? השתמש בכניסה כדי לגלוש בתוכן זה יכול להשפיע על התוכן שתראו, לדוגמה, יציג אלבומים למשתמשי פרימיום בלבד אם אתם מחוברים לחשבון פרימיום השבתת צילום מסך כאשר אפשרות זו מופעלת, צילומי מסך ותצוגת האפליקציה תחת \'אחרונים\' מושבתות. הפעל את ספק המילים של LrcLib הפעלת ספק מילות השיר KuGou הסתר תוכן בוטה גיבוי ושחזור גיבוי שחזור רשימת השמעה מיובאת גיבוי נוצר בהצלחה לא ניתן היה ליצור גיבוי שחזור הגיבוי נכשל שילוב דיסקורד Metrolist משתמשת בספריית KizzyRPC כדי לקבוע את סטטוס חשבון הדיסקורד שלך. זה כרוך בשימוש בחיבור Discord Gateway, דבר שעשוי להיחשב כהפרה של תנאי השימוש של Discord. עם זאת, לא ידוע על מקרים של השעיית חשבונות משתמש מסיבה זו. השימוש על אחריותך בלבד.\n\nMetrolist יחלץ רק את הטוקן שלך, וכל השאר מאוחסן באופן מקומי. התעלמות אפשרויות תצוגה מקדימה הפעלת נוכחות עשירה אודות גירסת אפליקציה גירסה חדשה זמינה מודלים של תרגום ניקוי מודלי תרגום ================================================ FILE: app/src/main/res/values-ja/metrolist_strings.xml ================================================ ローカル リモート チャート 戻る アルバムカバー 急上昇 全期間 高評価した曲 ダウンロード済み 人気のミュージックビデオ マイトップ キャッシュ済み プレイリストを同期 同期は無効です 画像を生成中です お待ちください キャンセル 歌詞を共有 カラーをカスタマイズ テキストカラー セカンダリテキストカラー バックグラウンドカラー キャッシュから削除 リンクをコピー すべて選択 すべて高評価 リンクをクリップボードにコピーしました 歌詞 すべて低評価 テキストとして共有 画像として共有 選択の最大制限 選択した項目を共有 この曲はすでにプレイリストに追加されています 歌詞をタップして該当の箇所から再生 スリム 似た曲 キャッシュした曲のプレイリストを表示 自動プレイリスト 高評価した曲のプレイリストを表示 ダウンロードした曲のプレイリストを表示 マイトップのプレイリストを表示 トークンを使用してログイン トークンを表示 トークンを編集 一般 プロキシ 言語 コンパクトな下部ナビゲーションバー 似た曲を再生 テーマに従う %d秒 %d%% リリースノート プレーヤーの背景 ぼかし プレーヤーボタンの色 情報 デフォルト 左右にスワイプして曲を変更 低評価数 高評価した曲を自動でダウンロード 説明 YouTubeにログインしてください 対応のリンクを開く 視聴回数 高評価数 YouTube Musicと同期します。後から変更できません。 更新日 %d回 グラデーション 新しいプレーヤーのデザイン 新しいミニプレーヤーのデザイン 曲を右にスワイプして次に再生、左にスワイプしてキューに追加 歌詞を自動でスクロール 日本語の歌詞にローマ字を追加 韓国語の歌詞にローマ字を追加 高度なログイン方法です。ウェブポータルの代わりに、ログイントークンを直接入力・更新でき、複数のデバイスでのログインを高速化できます。無効な形式のトークンは使用できません アカウントと自動で同期 その他のコンテンツ デフォルトのライブラリタブを変更 ホームタブのおすすめの曲の基準 最後に聴いた曲 キューの最後まで再生したあと、自動で似た曲を追加します M3U形式のプレイリストをインポート CSV形式のプレイリストをインポート 同期/リモートプレイリストへのローカル曲の追加はサポートしていません ハートをつけた曲を自動でダウンロードします ミニプレーヤーのスワイプ感度 キャッシュした曲をすべて削除しますか? 画像のキャッシュをすべて削除しますか? ダウンロードした曲をすべて削除しますか? オフ アプリの設定を開けませんでした 全期間 過去24時間 過去1週間 過去1ヶ月間 過去1年間 マイトップリストの曲数 再生履歴の期間 チャンネル登録 チャンネル登録済み %1$d%% 再生中 閉じる プレーヤーにサムネイルを表示しない 再生画面に表示されるアルバムアートをアプリアイコンに置き換えます %1$d秒進む %1$d秒戻る シーク時間を段階的に増やす 連続してシーク操作を行うごとに5秒ずつ加算します 全曲リピート再生しているときは追加の曲を自動で読み込まない 全曲リピート再生しているときは似た曲の追加を行いません アップロード済み アップロード済み ラジオを開始中 曲をスワイプしてプレイリストから削除 アップロードした曲のプレイリストを表示 カバー画像を変更 プレイリストのカバー画像を変更するには、アカウントが電話番号に紐付けられ、YouTube Musicで確認済みである必要があります。 新しいカバー画像が反映するまでに時間がかかる場合があります。 写真から選択 変更した画像を削除 プロキシ設定 プロキシのユーザー名 プロキシのパスワード プロキシ認証 曲名を強調して表示 アーティスト名より曲名を目立つように表示します キリル文字 ローマ字 歌詞にローマ字を追加 ロシア語の歌詞にローマ字を追加 ウクライナ語の歌詞にローマ字を追加 ベラルーシ語の歌詞にローマ字を追加 キルギス語の歌詞にローマ字を追加 セルビア語の歌詞にローマ字を追加 ブルガリア語の歌詞にローマ字を追加 行ごとに言語を判別(実験的) キリル文字の言語は、曲全体ではなく行ごとに判別されます。 確認 この機能は実験的で、動作が不安定になる場合があります。\n通常、言語は曲全体から判別されますが、この設定をオンにすると行ごとに判別されます。複数の言語が混在する曲に対応できますが、判別結果が常に正しいとは限りません(例:ウクライナ語特有の文字が含まれていない歌詞は、ロシア語として判別される場合があります)。\n問題がない場合は、この設定はオフにすることをおすすめします。 歌詞にローマ字を追加 インターフェース プライバシーとセキュリティ プレーヤーとコンテンツ ストレージとデータ システムと概要 アップデーター 自動でアップデートを確認する 新しいバージョンがある場合は通知を受け取る アップデートが利用可能です アプリをアップデート 新しいバージョンの通知 オフロード再生 音声の再生にオフロードのオーディオパスを使用します。この設定をオフにすると消費電力が増加する可能性がありますが、再生や音声の処理に関する問題の改善に役立つ場合があります マケドニア語の歌詞にローマ字を追加 外部のサービスと連携 ユーザー名 パスワード Last.fmと連携 再生履歴を送信 再生中の曲を反映する 再生履歴の設定 送信する最小の再生時間 送信する再生の割合 送信までの時間 概要 続きを読む 折りたたむ アーティストページ アーティストの説明を表示 チャンネル登録者数を表示 月間視聴者数を表示 すべての曲をオフライン再生用にダウンロードします このプレイリストからダウンロードしたすべての曲を削除します ダウンロードが進行中です このプレイリストを他の人と共有します このプレイリストを完全に削除します このプレイリストをYouTube Musicと同期します アルバムアートを切り抜く 動画のサムネイルを切り抜いて正方形の比率にします メインカラー アクセントカラー 歌詞を発光させる 再生中の歌詞を発光させて強調します 歌詞の提供元にBetter Lyricsを使用 単語ごとに同期した歌詞の表示にBetter Lyricsを使用します 歌詞の提供元にSimpMusic Lyricsを使用 単語ごとに同期した歌詞の表示にSimpMusic Lyricsを使用します 同期 シャッフル再生時、プレイリストやアルバム内の曲をすべて再生したあとに似た曲を再生します プレイリスト/アルバムの曲を優先 Wrappedカードを表示 無音の部分をスキップ 無音の部分は早送りせずに自動でスキップします 中国語の歌詞にローマ字を追加 Google Cast ログインしています… 動画の曲を非表示 この曲の詳細を表示します ミニプレーヤー イコライザー シャッフル再生を保持 新しい曲やプレイリストを再生してもシャッフル再生状態を維持します シャッフル/リピート再生を保持 アプリを再起動したあともシャッフル/リピート再生を維持します ミュート時に再生を停止 プレーヤーを展開中は画面をオンに保つ 歌詞のタイミングを調整 Chromecastなどのキャスト対応のデバイスに音声をキャストできます 高評価/低評価を反映する Metrolistで曲に高評価/低評価をつけると、Last.fmでもラブ/アンラブされます タイトルやアーティスト名を変更します この曲をもとに似た曲を再生します キューの先頭に追加します キューの最後に追加します ライブラリに保存します オフライン再生用に保存します プレイリストに追加します YouTube Musicから最新のメタデータを取得します このコンテンツのリンクを共有します このコンテンツを完全に削除します 曲の再生速度と音程を変更します オーディオイコライザーを調整します 動的アイコン ピュアブラックのミニプレーヤー Listen Together 再生/停止 METROLIST Apple Music カラオケ なし 続行 フェード 発光 スライド アルバムアート プレイリストを保存しました システムイコライザー 無効 エラー 開く タイトルをコピーしました アーティスト名をコピーしました アルバムアート 次へ 高評価 再生していません 音楽プレーヤー サーバーURL サーバーを選択 カスタムサーバー カスタムサーバーを使用 ユーザー名 接続済み 再接続中… 切断中 接続中… 接続エラー ルームを作成 ルームに参加 ルームコード ミュート ミュート解除 ログ Listen Together 無効なルームコードです ルームコード ルームから退出 参加 作成 接続 切断 作成 参加 コピー クリップボードにコピーしました キック ホスト あなた ユーザー名を入力 ユーザー名が必要です。 同期 コードをコピー 完全にブロック ユーザー管理 ブロックしたユーザー 誰もブロックしていません ブロックを解除 アプリがクラッシュしました クラッシュログを共有 クラッシュレポートを共有 Metrolistのクラッシュレポート 閉じる クラッシュログがありません ダイナミック クリムゾン ローズ ディープパープル インディゴ スカイブルー シアン ティール ライトグリーン ライム 黄色 アンバー オレンジ ディープオレンジ ブラウン グレー ブルーグレー 戻る ピュアブラックモード ライトモード ダークモード システムに従う 曲の無音の部分を早送りします 単語ごとのアニメーションスタイル 歌詞のテキストサイズ 歌詞の行間隔 データがありません プレイリストを作成 イコライザープロファイルがありません プロファイルをインポート プロファイルを削除 インポートエラー ターンテーブル 再生コントロール付きのウィジェット 再生と高評価の操作ができる円形のウィジェット ルームを作成し、コードを友達と共有します 友達とリアルタイムで音楽を一緒に聴くことができます。ルームを作成してホストするか、コードを使って既存のルームに参加できます。 音楽を再生していない状態でルームを作成し、その後ほかのアプリに切り替えると、切断される場合があります。 参加リクエストが拒否されました 既存のルームに参加 ルームを作成中… ちょっと待って! 現在アプリが使用している容量(%1$s)より小さいキャッシュサイズ上限を選択しています。新しい上限に合わせるため、一部の%2$sが削除されることがあります。続行しますか? %sのアルバムアート これまでに再生した ユニークなアルバム数 あなたのトップアルバムは あなた専用のプレイリストが完成しました あなたのトップ5アルバム このアルバムを合計%d分再生しました %d分 今年のトップアーティスト %d分 今年のトップソング 今年のトップアーティストは トップアーティスト画像 合計%d分再生しました 最も再生した曲は 合計%d分再生しました 再生した ユニークなアーティスト数 再生した ユニークな曲数 これまでに聴いてきた音楽を振り返りましょう さあ、始めよう! Metrolistロゴ あなたのWrappedが完成しました! 今年あなたが夢中になった音楽を見てみましょう。 聴いてくれてありがとう Metrolistを制作したMO Agamyに特別な感謝を Wrappedを閉じる あなたの%sWrapped %dプロファイル %dバンド %1$sを削除しますか?この操作は元に戻せません。 ファイルを読み取れませんでした ファイルを開けません:%1$s イコライザープロファイルを適用できません:%1$s %sにキャスト中 進行状況:%s%% Metrolistで再生中 画像の作成に失敗しました:%s 再生中にエラーが発生しました プロキシURLを解析できません。 再生に失敗しました 再生中の曲はありません タップしてMetrolistを開く 前へ タップしてMetrolistを開く あなたはホストです あなたはゲストです 参加リクエスト 接続とメッセージのデバッグ 接続ログ ログはありません 参加リクエストを自動で承認 参加リクエストを手動で確認せず、自動的に承認します ホストの音量と同期 %1$sパレット Listen Togetherが設定されていません。”設定” ▶ ”連携” ▶ ”Listen Together” でサーバーURLを設定してください。 %1$sがルームへの参加を希望しています このユーザーをセッションから削除します %d人ブロックしています 所有権を譲渡 ルームに参加中はユーザー名を変更できません 削除 Listen Togetherのイベント通知 ルーム %sに参加中… 予期しないエラーが発生しました。問題の解決に役立てるため、クラッシュレポートを共有してください。 ルームに参加中 ホストに提案を送信しました! ホストの承認を待っています このユーザーの参加リクエストをブロックし、提案を非表示にします ゲストの音量はホストの音量に同期されます ディスプレイを対応している中で最も高いリフレッシュレートで強制的に動作させます ホストに提案 保留中の提案 サーバーやユーザー名などを設定します 保留中のリクエスト 未設定 ルームをホスト中 高リフレッシュレート 接続中のユーザー ホストによってユーザーがブロックされました %1$sが%2$sをリクエストしました 拒否 ルームコードを入力 承認 このユーザーをルームのホストにします すべて再生 ルームを作成しました:%s クロスフェード 曲の切り替わりをなめらかにします ベータ機能 曲のクロスフェードは新機能のため、不具合が発生する場合があります。問題があればご報告してください。\n\n技術的な制限により、クロスフェードを使用中はオフロード再生が無効になります。 オン クロスフェードの長さ 曲間のない曲では無効にする 曲間のない曲ではクロスフェードしません クロスフェードを使用中は有効にできません Together AI歌詞翻訳 歌詞を翻訳中... 翻訳しました 提供元 ベースURL APIキー モデル 翻訳方法 翻訳先の言語 API認証情報 翻訳 文字起こし APIキーが必要です APIキーを入力してください 翻訳できる歌詞がありません 歌詞がありません 翻訳先の言語を設定してください 翻訳結果が正しくありません 不明なエラーが発生しました 翻訳に失敗しました 音楽認識 タップして開始 聴き取り中… 処理中… 一致する曲が見つかりませんでした 認識エラー もう一度試す 認識履歴 認識履歴を削除 認識履歴をすべて削除しますか? 履歴から削除 もう一度聴く Metrolistで再生 CSV列の対応設定 1行目をヘッダーとして扱う アーティスト名の列 曲名の列 YouTube URLの列(任意) 続行 CSVをインポート中 最近変換した項目 列%d YouTubeショートを非表示 トップバーにListen Togetherを表示 Listen Togetherをナビゲーションバーから非表示にし、トップバーに表示します キュー内の曲の重複を防止 同じ曲がキューに重複して追加されないようにします 意味を対象の言語に翻訳 発音を対象の文字体系に変換 APIキーを入手 無料および有料モデルについては、 openrouter.ai をご覧ください APIキーは、 platform.openai.com/api-keys で取得できます 2025 APIキーは、 console.anthropic.com/settings/keys で取得できます APIキーは、 aistudio.google.com/apikey で取得できます APIキーは、 perplexity.ai/settings/api で取得できます APIキーは、 console.x.ai で取得できます APIキーは、 deepl.com/pro-api for free and paid keys で取得できます 丁寧さ デフォルト より丁寧 よりカジュアル ステータス オンライン 退席中 取り込み中 ボタン ボタン 1 ボタン 2 ログインに成功しました! この機能は、KizzyRPCライブラリを使用してDiscordのゲートウェイに接続し、再生状況を表示します。同様の使用方法によるアカウント停止の報告はありませんが、この方法はDiscordによって公式にサポートされておらず、利用規約違反とみなされる場合があります。トークンはデバイス内でのみ取得され、第三者のサーバーに送信されることはありません。自己責任で使用してください。 アクティビティの種類 プレイ中 再生中 視聴中 大会に参加中 利用可能な変数:{song_name}, {artist_name}, {album_name} 再生状況のプレビュー プレゼンス Discordにサインインして、再生中の内容を共有しよう Metrolistで再生中 Metrolistで視聴中 Metrolistで大会に参加中 アクティビティ名 アクティビティのカスタム名(空欄の場合はデフォルトを使用) 詳細設定 再生状況の追加のカスタマイズオプションを表示します 単色 Bluetooth接続時に再生を再開 ヒンディー語の歌詞にローマ字を追加 パンジャブ語の歌詞にローマ字を追加 ローマ字の歌詞をメインに表示 表示サイズ 再起動 再起動が必要です 表示サイズの変更は、アプリを再起動すると反映されます。再起動しますか? クイック再生 クイック再生にピン留め クイック再生のピン留めを解除 ホーム画面の表示順をランダムにする ホーム画面の各項目をランダムに並び替えます %1$sに似たおすすめ %1$sをよく聴いているため %1$sに似たおすすめ %1$sをもとにしたおすすめ %1$sのファンにおすすめ コミュニティのおすすめ コミュニティ主導の歌詞データベースです 中国最大のオンライン音楽サービスです 他の歌詞が利用できない場合は、YouTube Musicの歌詞が自動的に表示されます。YouTube Musicの歌詞は通常、再生に合わせて表示されません。 歌詞の提供元にLyricsPlusを使用 複数のソースの歌詞を提供する提供元です 歌詞の提供元 使用する歌詞の提供元を選択します 歌詞の提供元の優先順位 ドラッグして提供元を並び替えます。上にあるほど優先順位が高くなります。 変更履歴 変更履歴はありません https://github.com/MetrolistGroup/Metrolist/releases GitHubを開く バージョン バージョン: %s アップデート設定 アップデート アップデートを確認中… 最新: %s アップデートを確認 変更履歴を非表示 変更履歴を表示 アップデートの確認に失敗しました: %s デフォルトに設定 スリープタイマーのデフォルト値を%d分に設定しました ”設定” ▶ ”コンテンツ”にあります 回再生 エピソードの保存に失敗しました エピソードの削除に失敗しました ポッドキャストの登録に失敗しました ポッドキャストの登録解除に失敗しました 楽曲リクエストを自動で承認 ゲストからの楽曲リクエストを自動で承認し、キューに追加します プレイリストをインポート中 ライブラリデータを保持しますか? プレイリストとライブラリデータを保持しますか?ダウンロードした曲は、いずれの場合も保持されます。 保持 削除 主任開発者 共同開発者 共同開発者 GNU一般公衆ライセンス v3.0 無料でオープンソースのソフトウェアで、使用、学習、共有、改良が可能です。 Discordサーバー Telegramチャンネル ウェブサイト Instagram GitHub リポジトリを表示 %1$s • %2$s 私の活動を気に入ってもらえましたか? コーヒーをおごる コミュニティと情報 METROLIST お気に入りの曲を再生しますか? はい このプロジェクトはパレスチナを支持しています 🇵🇸 ポッドキャスト ポッドキャストを表示 ポッドキャストチャンネル 最新のエピソード あなたの番組 新しいエピソード あとで聴く あとで保存 ”あとで聴く”プレイリストに追加 保存した曲から削除 ポッドキャストをライブラリに保存 %dエピソード バックアップを復元しますか? バックアップからアプリのデータを復元します。 復元後、再度ログインが必要になります。このアカウントはサインアウトされます: 復元 以前のアカウントを確認中… アカウントが見つかりません 音楽認識 周囲で流れている曲を認識できるウィジェット タップして開始 聴き取り中… 認識中… 一致する曲が見つかりませんでした。もう一度お試しください 認識に失敗しました エラーが発生しました。もう一度お試しください 不明な曲 不明なアーティスト 曲を認識します 音楽認識 ウィジェットから曲を認識している間、通知を表示します 音声を録音中… エピソード チャンネル 自動プレイリスト ダウンロードしたエピソード 登録しているチャンネルはありません ダウンロードしたエピソードはありません %dチャンネル チャンネルを表示 プロフィール スリープタイマーを自動的に開始 指定した時刻にスリープタイマーを自動的に開始します スリープタイマーを自動で開始する曜日と時刻を設定します 繰り返し 毎日 月曜日~金曜日 平日/ 週末 週末(土・日) カスタム 開始時刻 終了時刻 月曜日 火曜日 水曜日 木曜日 金曜日 土曜日 日曜日 タイマー終了時のときの曲が終わってから停止 終了前の1分間でフェードアウト 曲をアップロード アップロード中… %1$d/%2$d アップロードが完了しました アップロードに失敗しました ファイルサイズが大きすぎます(最大300MB) 対応していない形式です。mp3、m4a、wma、flac、oggを使用してください アップロードした曲を削除 このアップロードした曲を削除しますか?この操作は元に戻せません。 アップロードした曲を削除しました アップロードした曲の削除に失敗しました アップロードした曲を削除 %1$d件のアップロードした曲を削除しますか?この操作は元に戻せません。 %1$d件の曲を削除しました 削除中… プレイリストをエクスポート CSV形式でエクスポート M3U形式でエクスポート プレイリストを正常にエクスポートしました プレイリストのエクスポートに失敗しました 共有 ドキュメントに保存 音楽認識 ================================================ FILE: app/src/main/res/values-ja/strings.xml ================================================ ホーム アーティスト アルバム 再生リスト %d 個を選択済み 履歴 統計 ムードとジャンル アカウント おすすめ 何曲か再生するとおすすめを生成します 新作アルバム 忘れられているお気に入り 視聴を維持 あなたのYouTubeの再生リスト これに似ている 今日 昨日 今週 先週 最も聴いた曲 最も聴いたアーティスト 最も聴いたアルバム 検索 YouTube Music を検索… ライブラリを検索… ライブラリ いいね済み ダウンロード済み すべて 動画 アルバム アーティスト 再生リスト コミュニティの再生リスト おすすめの再生リスト ブックマーク済み 見つかりませんでした 曲のライブラリはここに表示されます アーティストのライブラリはここに表示されます アルバムのライブラリはここに表示されます あなたの再生リストはここに表示されます ライブラリから 別のバージョン いいねした曲 ダウンロードした曲 再生リストが空です 「%s」の再生リスト上の全曲をダウンロード済みから削除しますか? 「%s」の再生リストを削除してもよろしいですか? 再試行 ラジオ シャッフル リセット 詳細 編集 ラジオを再生 再生 次に再生 キューに追加 ライブラリに追加 すべてライブラリに追加 ライブラリから削除 すべてライブラリから削除 ダウンロード ダウンロード中 ダウンロードを削除 再生リストをインポート 再生リストに追加 アーティストを表示 アルバムを表示 再取得 共有 削除 履歴から削除 再生リストから削除 キューから削除 オンラインで検索 同期 高度 速度とピッチ 追加日時 曲名 アーティスト リリース年 曲数 長さ 再生時間 カスタム メディア ID MIME タイプ コーデック ビットレート サンプルレート ラウドネス 音量 ファイルサイズ 不明 クリップボードにコピー 歌詞を編集 歌詞を検索 曲を編集 曲名 曲のアーティスト 曲名は空白にできません。 曲のアーティストは空白にできません。 保存 再生リストを選択 再生リストを編集 再生リストを作成 再生リストの名前 再生リストの名前は空白にできません。 アーティストを編集 アーティスト名 アーティスト名は空白にできません。 重複 重複をスキップ 気にせず追加 曲はすでに再生リストにあります %d の曲はすでに再生リストにあります %d 曲 %d 件のアーティスト %d 個のアルバム %d 件の再生リスト %d 週間 %d か月 %d 年 再生リストをインポートしました 再生リストから「%s」を削除しました 再生リストが同期されました 元に戻す 歌詞が見つかりません スリープタイマー 曲の終わり %d 分 ストリームが利用できません ネットワーク接続がありません タイムアウトしました 不明なエラー いいね すべていいね いいねを削除 すべてのいいねを削除 シャッフル ON シャッフル OFF リピートモード OFF 現在の曲をリピート キューをリピート すべての曲 検索した曲 音楽プレーヤー 設定 外観 テーマ ダイナミックテーマを有効化 ダークテーマ オン オフ システムに従う ピュアブラック ナビゲーションタブのカスタマイズ プレーヤー プレーヤーの文字揃え 歌詞テキストの位置 中央 プレーヤーのスライダーの形 標準 くねくね ほか 起動時に開くタブ 格子のマス目の大きさ コンテンツ ログイン ログインしていません コンテンツの既定の言語 コンテンツの既定の国 システムに従う プロキシを有効化 プロキシの種類 プロキシの URL 適用するには再起動してください プレーヤーと音声 音質 自動 再生キュー 再生キューを保持 アプリ起動時に前回のキューを復元 追加の曲を自動で読み込む キューの最後まで再生した時、可能なら自動で曲を追加 無音部分をスキップ 音声の正規化 エラー発生時に自動で次の曲を再生 継続的な再生体験を維持します タスクを削除したら音楽を停止 イコライザー 保存領域 キャッシュ 画像のキャッシュ 曲のキャッシュ 最大キャッシュサイズ 無制限 すべてのダウンロードを消去 画像の最大キャッシュサイズ 画像のキャッシュを消去 曲の最大キャッシュサイズ 曲のキャッシュを消去 %s 使用中 プライバシー 再生履歴 再生履歴を一時停止 再生履歴を消去 すべての再生履歴を消去しますか? 検索履歴 検索履歴の記録を一時停止 検索履歴を消去 すべての検索履歴を消去しますか? スクリーンショットを無効にする スクリーンショットと最近使用したアプリの表示を無効にします。 歌詞の提供元 LrcLib を使用 歌詞の提供元 KuGou を使用 露骨な内容のコンテンツを非表示 バックアップと復元 バックアップ 復元 インポートした再生リスト バックアップの作成に成功しました バックアップを作成できませんでした バックアップの復元に失敗しました Discord 統合 Metrolist は KizzyRPC ライブラリを使い、Discord アカウントのステータスを設定します。これは Discord ゲートウェイ接続を使うので、Discord の利用規約に違反する可能性があります。しかし、この理由でユーザーのアカウントが停止された例は確認されていません。自己責任でご利用ください。\n\nMetrolist はトークンを抽出するだけです。それ以外はすべて端末内に保存されます。 非表示 オプション プレビュー ログイン失敗 ログアウト リッチプレゼンスを使用 アプリについて アプリのバージョン 最新版あり 翻訳モデル 翻訳モデルを消去 例えば、Premiumのアカウントでログインした時に、Premium限定のアルバムが表示されるようになります 表示されるコンテンツをアカウントと結び付ける ログイン ================================================ FILE: app/src/main/res/values-km/metrolist_strings.xml ================================================ លើឧបករណ៍ តារាង ត្រឡប់ក្រោយ គម្របអាល់ប៊ុម វីដេអូចម្រៀងពេញនិយម កំពុងពេញនិយម សប្តាហ៍ ខែ ឆ្នាំ បន្តជាប់គ្នា ចូលចិត្ត បានទាញយក ================================================ FILE: app/src/main/res/values-ko/metrolist_strings.xml ================================================ 로컬 원격 차트 뒤로 앨범 커버 인기 뮤직 비디오 트렌드 이미지 생성 중 취소 더보기 간략히 링크 복사 닫기 가사 링크를 클립보드에 복사했습니다 +%1$d초 앞으로 +%1$d초 뒤로 새로운 플레이어 디자인 새로운 미니 플레이어 디자인 앱 언어 YouTube에 로그인되어 있지 않습니다 조회수 좋아요 구독 구독중 아이디 비밀번호 로그인 중… 미니 플레이어 잠깐! 다음 당신의 최애 앨범은 앨범 아트 올해 당신의 최애 아티스트는 메트로리스트 2025 이퀄라이저 이퀄라이저 프로필 없음 프로필 가져오기 시스템 이퀄라이저 프로필 삭제 제목 복사됨 아티스트 복사됨 앨범 아트 재생/일시중지 다음 좋아요 좋아요 표시 됨 가사 공유 캐시된 재생목록 열기 Apple Music 가사 글씨 크기 가사 줄간격 노래방 ================================================ FILE: app/src/main/res/values-ko/strings.xml ================================================ 노래 아티스트 앨범 재생목록 %d 선택됨 기록 통계 분위기 및 장르 계정 빠른 선곡 빠른 선곡을 생성하기 위해 몇 곡을 들어보세요 최신 앨범 오늘 어제 이번 주 저번 주 가장 많이 재생한 음악 가장 많이 재생한 아티스트 가장 많이 재생한 앨범 검색 YouTube Music 검색… 보관함 검색… 보관함 좋아요 표시 됨 다운로드 됨 모두 노래 비디오 앨범 아티스트 재생목록 커뮤니티 재생목록 추천 재생목록 북마크 됨 검색 결과가 없음 보관함에서 좋아요 표시한 노래 다운로드 한 노래 재생목록이 비어있습니다 재시도 라디오 셔플 초기화 세부 정보 수정 라디오 시작 재생 다음 노래 재생 목록에 추가 보관함에 추가 보관함에서 삭제 다운로드 다운로드 중 다운로드 제거 재생목록 불러오기 재생목록에 추가 아티스트 보기 앨범 보기 새로고침 공유 삭제 기록에서 제거 온라인 검색 동기화 고급 추가된 날짜 이름 아티스트 년도 곡 개수 길이 재생 시간 맞춤 정렬 미디어 ID MIME 타입 코덱 비트레이트 샘플레이트 라우드니스 음량 파일 크기 알 수 없음 클립보드에 복사됨 가사 편집 가사 검색 노래 편집 노래 제목 노래 아티스트 노래 제목은 비워둘 수 없습니다. 음악 아티스트는 비워둘 수 없습니다. 저장 재생목록 선택 재생목록 편집 재생목록 만들기 재생목록 이름 재생목록 이름은 비워둘 수 없습니다. 아티스트 편집 아티스트 이름 아티스트 이름은 비워둘 수 없습니다. %d곡 %d 아티스트 %d 앨범 %d 재생목록 %d주 %d개월 %d년 재생목록을 가져왔습니다 재생목록에서 \"%s\"을(를) 삭제했습니다 재생목록 동기화됨 되돌리기 가사를 찾을 수 없음 수면 타이머 노래 끝 %d분 스트림을 찾을 수 없음 네트워크 연결 없음 타임아웃 알 수 없는 오류 좋아요 좋아요 취소 셔플 활성화 셔플 비활성화 반복 해제 현재 곡 반복 대기열 반복 모든 노래 검색된 노래 음악 플레이어 설정 모양 동적 테마 활성화 다크 테마 시스템 퓨어 블랙 시작 시 열리는 탭 내비게이션 탭 사용자 정의 가사 위치 왼쪽 중앙 오른쪽 콘텐츠 로그인 기본 콘텐츠 언어 기본 콘텐츠 국가 시스템 기본값 프록시 활성화 프록시 타입 프록시 URL 변경사항을 적용하려면 다시 시작하세요 플레이어 및 오디오 오디오 품질 자동 높음 낮음 대기열 유지 무음 건너뛰기 오디오 음량 평준화 이퀄라이저 저장공간 캐시 이미지 캐시 노래 캐시 캐시 최대 크기 무제한 다운로드 한 곡 모두 지우기 이미지 캐시 최대 크기 이미지 캐시 지우기 노래 캐시 최대 크기 노래 캐시 지우기 %s 사용됨 프라이버시 재생 기록 일시 중지 재생 기록 지우기 모든 재생 기록을 지우시겠습니까? 검색 기록 일시 중지 검색 기록 지우기 모든 검색 기록을 지우시겠습니까? KuGou 가사 제공자 활성화 백업 및 복구 백업 복구 가져온 재생목록 백업이 성공적으로 생성되었습니다 백업을 생성할 수 없습니다 백업을 복구하지 못했습니다 정보 앱 버전 새 버전을 사용할 수 있습니다 번역 모델 번역 모델 지우기 YouTube 재생목록 재생목록에서 제거 대기열에서 제거 계속 듣기 보관함에서 모두 제거 보관함 아티스트가 여기에 표시됩니다 플레이어 슬라이더 스타일 앱이 시작될 때 마지막 대기열 복원 기타 \"%s\" 재생목록을 삭제하시겠습니까? 보관함 노래가 여기에 표시됩니다 보관함 앨범이 여기에 표시됩니다 재생목록이 여기에 표시됩니다 다운로드한 노래 저장소에서 \"%s\" 재생 목록의 모든 노래를 정말로 제거하시겠습니까? 보관함에 모두추가 템포와 피치 중복 중복 건너뛰기 무시하고 추가 음악이 이미 재생목록에 있습니다 %d곡이 이미 재생목록에 있습니다 테마 플레이어 텍스트 맞춤 플레이어 기본값 그리드 셀 크기 작게 크게 로그인되지 않음 대기열 자동으로 더 많은 노래 불러오기 오류 발생 시 다음 곡으로 자동 건너뛰기 스크린샷 비활성화 가능하다면 대기열 끝에 도달하면 자동으로 노래를 더 추가합니다 다시 듣기 LrcLib 가사 제공자 활성화 선정적인 내용 숨기기 미리보기 로그인 실패 Discord 통합 Metrolist은 KizzyRPC 라이브러리를 사용하여 Discord 계정의 상태를 설정합니다. 여기에는 Discord Gateway 연결을 사용하는 것이 포함되며, 이는 Discord의 TOS 위반으로 간주될 수 있습니다. 그러나 이러한 이유로 사용자 계정이 정지된 사례는 알려진 바가 없습니다. 사용에 따른 책임은 본인에게 있습니다. \n \nMetrolist은 토큰만 추출하며 그 밖의 모든 내용은 로컬에 저장됩니다. 옵션 로그아웃 모두 좋아요 좋아요 모두 취소 구불구불 측면 잊고 있던 좋은 음악 다른 버전 다시 보지 않기 검색 내역 앱 종료시 음악 중지 표시되는 콘텐츠에 영향을 끼칠 수 있으며, 예를 들어 Premium 계정으로 로그인했다면 Premium 한정 앨범이 표시됩니다 로그인한 계정으로 콘텐츠 탐색 아래 아티스트와 유사한 음악 추천 끊김 없는 재생 경험 보장 이 설정을 켜면, 스크린샷이 비활성화되고 최근 앱에서 보이는 앱 화면이 숨겨집니다. 활동 상태 공유 로그인 ================================================ FILE: app/src/main/res/values-lt/metrolist_strings.xml ================================================ Metai Atšaukti Mėgiami Atsisiuntimai Mano top Talpykloje Įkėlimai Teksto spalva Antrinė teksto spalva Fono spalva Pašalinti iš talpyklos Kopijuoti nuorodą Pasirinkti viską Pridėti viską prie mėgiamų Topai Grįžti Albumo viršelis Populiariausi muzikiniai vaizdo klipai Savaitės Mėnesiai Tęstinis Nuotolinė istorija Vietinė istorija Tendencijos Įkėlimai Sinchronizuoti grojaraštį Sinchronizavimas išjungtas Pastaba: Tai leidžia sinchronizuoti su YouTube Muzika. To NEGALIMA pakeisti vėliau. Generuojamas paveikslėlis Prašome palaukti Dalintis dainos žodžiais Dalintis tekstu Dalintis paveikslėliu Maksimalus pasirinkimų skaičius Dalintis pasirinktais Spalvų nustatymai Pridėti viską prie nemėgiamų Atnaujinimo data Nuoroda nukopijuota į iškarpinę Paleidžiamas radijas Dabar groja Dainos žodžiai Uždaryti Slėpti grotuvo miniatiūrą Grotuve pakeisti albumo paveikslėlį programėlės logotipu Jau grojaraštyje: %d kartą %d kartus %d kartų %d kartų +%1$d sekundžių į priekį -%1$d sekundžių atgal Progresyvus sekimas Jei įjungta, prie kiekvieno praleidimo palaipsniui prideda po 5 sekundes Panašus turinys Grotuvo fono išvaizda Pritaikyti prie temos Gradientas Nauja grotuvo išvaizda Nauja mini grotuvo išvaizda Suliejimas Grotuvo mygtukų spalvos Numatytoji Įjungti dainos keitimą braukiant Braukti į kairę, kad pridėti dainą į eilę, arba į dešinę, kad ji būtų sekanti eilėje Paspaudus pakeisti dainos žodžius Automatinis dainos žodžių slinkimas Siauras Siauras apatinis navigacijos meniu Automatiniai grojaraščiai Rodyti grojaraštį \"Mėgiami\" Rodyti grojaraštį \"Atsisiuntimai\" Rodyti grojaraštį \"Populiariausi\" Rodyti grojaraštį \"Saugykloje\" Rodyti grojaraštį \"Įkėlimai\" Prisijungti naudojant žetoną Prilieskite kad matytumėte žetoną Prilieskite dar kartą, kad kopijuoti ar redaguoti Įjungti SimpMusic dainos tekstus Naudoti SimpMusic dainų tekstų tiekėją sinchronizuotiems dainų tekstams Sinchronizuoti Rodyti mažiau Kūrėjo puslapis Rodyti atlikėjo aprašymą Rodyti prenumeratorių skaičių Rodyti klausytojų skaičių per mėnesį Atsisiųsti visas dainas klausymui neprisijungus prie ryšio Ištrinti visas atsiųstas dainas iš šio grojaraščio Atsiunčiama Dalintis grojaraščiu su kitais Panaikinti grojaraštį visam laikui Sinchronizuoti grojaraštį su YouTube Muzika Pažymėti, kad patinka Galimas atnaujinimas Programėlės naujinimai Greita prieiga prie labiausiai grojamo kūrinio Pirminė spalva Tretinė spalva Banguojantis Panaikinti pasirinktiną nuotrauką Bendra Pakeisti numatytąją bibliotekos žetoną Nustatyti greituosius pasirinkimus Remiantis paskutine klausyta daina Programėlės kalba Konfigūruoti proxy Proxy vartotojo vardas Proxy slaptažodis Įjungti autentifikavimą Išjungtas Automatiškai tikrinti, ar yra atnaujinimų Atnaujinimas Vartotojo vardas Slaptažodis Last.fm integracija Įjungti skroblavimą Romanizuoti dabartinį kūrinį Teksto poslinkis Sąsaja Privatumas ir Saugumas Grotuvas ir Turinys Talpykla ir Duomenys Sistema ir Apie Įjungti pranešimus apie atnaujinimus Pranešimai apie naujas versijas Įjungti perkėlimą Naudoti kitą garso atkūrimo metodą. Išjungiant šį nustatymą gali padidėti baterijos sąnaudos, tačiau gali būti naudinga, jei patiriate problemų su garso atkūrimu arba garso apdorojimu „Google Cast” Įjungti garso perdavimą „Chromecast” ir kitiems perdavimą palaikantiems įrenginiams Romanizuoti makedonų kalbos tekstus Integracijos Peržiūros Mėgsta Nemėgsta Prenumeruoti Prenumeruota %d sekundė %d sekundės %d sekundžių %d sekundžių Išjungti papildomų dainų įkėlimą, kai įjungtas visko kartojimas Neįkelti automatiškai daugiau dainų ir panašaus turinio, kai visko kartojimo režimas yra įjungtas Sustabdyti muziką, kai medijos garsas yra nutildytas Kirilica Romanizacija Dainų tekstų romanizacija Romanizuoti japonų kalbos dainų tekstus Romanizuoti korėjiečių kalbos dainų tekstus Romanizuoti kinų kalbos dainų tesktus Paslėpti muzikos klipus Žiūrėti dainos informaciją Pakeisti pavadinimą arba kūrėją Sukurti stotį remiantis šiuo elementu Pridėti į eilės priekį Pridėtį į eilės galą „Apple Music” Išsaugoti į biblioteką Padaryti pasiekiamą atkūrimui neprisijungus Pridėtį į grojaraštį Gauti naujausią informaciją iš „YouTube Muzika” Dalintis nuoroda šiam elementui Panaikinti šį elementą visam laikui Pakeisti dainos tempą ir aukštį Koreguoti garso ekvalaizerį Įjungti prisitaikančią ikonėlę Mini-grotuvas Juodas mini-grotuvas Palaukite! Jūs pasirinkote talpyklos dydžio limitą mažesnį nei programėlė dabar naudoja (%1$s). Jei tęsite, programėlė panaikins talpykloje saugojamų %2$s, kad susilygintų su nauju limitu. Tęsti vis tiek? Tęsti Žodis po žodžio animacijos stilius Joks išblukti Švytėti Slinkti Karaoke Dainų teksto dydis Dainų teksto eilutės tarpas %s Albumo viršelis Jūs klausėte unikalių albumų Jūsų top albumas yra Jūsų asmeninis grojaraštis yra paruoštas Jūsų top 5 albumai Jūs klausėte šio albumo %d minučių %d minučių Nėra duomenų Jūsų top metų kūrėjai %d minučių Jūsų top metų dainos Albumo viršelis Jūsų top metų kūrėjas yra Top kūrėjo nuotrauka Jūs jų išklausėte %d minučių Jūsų daugiausia kartų grota daina yra Jūs praklausėte %d minučių Jūs klausėtės unikalių kūrėjų Jūs klausėtės unikalių dainų METROLIST laikas pamatyti, ką jūs klausėtės pirmyn! „Metrolist” logotipas 2025 JŪSŲ WRAPPED YRA PARUOŠTAS! Metas pamatyti, kas jums patiko šiais metais. Ačiū, kad klausotės Ypatingas ačiū MO Agamy, kad sukūrė „Metrolist” Uždaryti Wrapped Jūsų %s Wrapped Sukurti grojaraštį Grojaraštis išsaugotas %d Profilis %d Profiliai %d Profilių %d Profilių Ekvalaizeris Nėra ekvalaizerio profilių Įkelti profilį Sistemos ekvalaizeris %d juosta %d juostos %d juostų %d juostų Ištrinti profilį Ar tikrai norite ištrinti %1$s? Šis veiksmas negali būti atšauktas. Failas negalėjo būti perskaitytas Negalėjome atidaryti failo: %1$s Įkėlimo klaida Klaida Klaida, įkeliant ekvalaizerio profilį %1$s Progresas %s%% Klausomasi „Metrolist” Atidaryti Klaida, kuriant nuotrauką %s Nukopijuotas pavadinimas Nukopijuotas kūrėjas Grojimo klaida Nepavyko išanalizuoti tarpinio serverio url. Atkūrimo klaida Albumo viršelis Nėra grojančios dainos Spauskite, kad atidarytumėte „Metrolist” Ankstesnis Leisti / pristabdyti Kitas Muzikos grotuvo valdiklis su atkūrimo valdikliais Apie Rodyti daugiau Apkarpyti albumo viršelį Priverstinai nustatykite kvadratinį kraštinių santykį apkirpdami vaizdo įrašų miniatiūras Braukite dainą, kad ją pašalintumėte iš grojaraščio Įjungti švytinčių dainų tekstų efektą Pridėkite šviečiančią animaciją ir atšokimo efektą prie aktyvių dainų tekstų Įjungti Better Lyrics Naudoti Better Lyrics tiekėją pažodžiui sinchronizuotiems dainų tekstams Pirma maišyti grojaraštį/albumą Maišant, pirma groti visas dainas iš grojaraščio/albumo, ir tik tada panašų turinį Rodyti Wrapped kortelę Greitasis perėjimas per tyliąsias dainos vietas Staigiai praleiskite tylą Peršokite per tylias vietas, vietoj pagreitinant atkūrimą Tai PAŽANGUS prisijungimo būdas. Kaip alternatyvą žiniatinklio portalui, galite tiesiogiai įvesti arba atnaujinti savo prisijungimo raktą čia. Pavyzdžiui, tai gali pagreitinti prisijungimą keliuose įrenginiuose. Atminkite, kad bet kokie neteisingi rakto formatai, kurių programa nesugeba išanalizuoti, nebus priimti Automatiškai sinchronizuoti su paskyra Daugiau turinio Koreguokite grojaraščio viršelį Pastaba: norint pakeisti grojaraščio viršelį, jūsų paskyra turi būti susieta su telefono numeriu ir patvirtinta „YouTube Muzika“. Pasirinkę paveikslėlį, palaukite, kol naujas viršelis pasirodys jūsų grojaraštyje. Pasirinkti iš bibliotekos Proxy Naudoti detalesnę informaciją, vietoje būsenos Aiškiai matyti dainos pavadinimą, o ne atlikėjo vardą Įjungti panašų turinį Automatiškai pridėti daugiau panašių dainų, kai pasiekiama eilės pabaiga Nuolatinis maišymas Paleidžiant naujas dainas ar grojaraščius, įjunkite atsitiktinę grojimą Atsiminti maišymą ir kartojimą Perkraunant programėlę, atsiminti maišymą ir kartojimo režimą %d%% ================================================ FILE: app/src/main/res/values-mfe/metrolist_strings.xml ================================================ Lokal Dan lot aplikasyon Bann graf Rétour Cover album Bann meyer vidéo clip Tandans Semene Mois Ans Kontinien Inn Like Inn Download Mo top Inn cache Inn upload Inn upload Senkroniz playlist Senkronizasyon dezaktivé Note: Sa paramet la permet senkronizasyon ek YouTube Music. Ou PA pu resi resanze li apre. Generate zimage Patienté silvouple Cancel Partaz bann parole Partaz antan ki text Partaz antan ki zimaz Limit maximal pu seleksyon Partaz seleksyon Personaliz kouler Kouler text Kouler secondaire pu text Kouler background Tir dan cache Kopié link Selekté tou Like tou Dislike tou Dat miz-azour Link inn kopié dan clipboard Demaraz radio Lektir en cours Bann paroles Fermé Kasiet thumbnail lekter la Ranplas art album ar logo aplikasyon dan lekter Deza dan playlist: %d fwa %d fwa %1$d second en avan -%1$d second en aryer Avansman progresif Si sa opsyon la aktif, sak fwa avansé ou rekilé li pu azout 5 second anplis lor sa mem aksyon ki en presedans Konteni similaire Style lekter dan background Swiv thème Degradé Nouvo design lekter Nouvo design mini lekter Flou Kouler bouton dan lekter Defaut Aktiv swipe pu sanz lamizik Swipe dan gos pou azout dan queue ou drwat pu zwé li apre Swipe lamizk pu tir li dan playlist Sanz bann parole kan click Scroll bann parole automikman Mins Bar navigasyon anba mins Playlist automatik Afis playlist \"Inn Like\" Afis playlist \"Inn Download\" Afis playlist \"Top\" Afis playlist \"Inn cache\" Afis playlist \"Inn Upload\" Login ar token Click pu afis token Click ankor pu kopié ou edit Sa method la enn method login AVANCÉ. Antan ki enn alternaif a portail web, ou pu bizin met ou update ou login token direkteman. Par examp, li kapav fer login pli rapid lor plizir aparey. Noté ki app la pa pu aksepté okenn format token ki invalid ek ki li pa resi analizé Senkroniz ar compte automikman Plis konteni Edit cover playlist Note: Ou compte bizin inn link ek enn limero téléphone et vérifié lor YouTube Music pu resi sanz cover playlist. Apre ki ou inn swazir n zimaz, patiente enn moment pu nouvo cover la aparet dan ou playlist. Soizir depi library Tir zimaz personalizé Zeneral Proxy Download tou sante pu zwe offline Tir tou sante inn download depi sa playlist la Pe Download Partaz sa playlist la ek lezot Retir sa playlist la net Senkroniz playlist ek Youtube Music Kouler primaire Kouler tertiaire Aktiv glowing effect pu bann paroles Azout animation glowing ek effet bounce lor bann paroles aktif Aktiv Better Lyrics Servi Better Lyrics pu paroles senkronize mot par mot Re-senkronize Zwe playlist/album en aleatoire en premie Kan en aleatoire, zwe tou sante depi playlist/album orizinal en premie, apre zwe bann cki similaire Montre Résumé ================================================ FILE: app/src/main/res/values-ml/strings.xml ================================================ ഹോം പാട്ടുകൾ കലാകാരന്മാർ ആൽബങ്ങൾ പ്ലേലിസ്റ്റുകൾ %d തിരഞ്ഞെടുത്തു %d തിരഞ്ഞെടുത്തു തിരയുക യൂട്യൂബ് സംഗീതം തിരയുക… ലൈബ്രറിയിൽ തിരയുക… എല്ലാം പാട്ടുകൾ വീഡിയോകൾ ആൽബങ്ങൾ ആർട്ടിസ്റ്റുകൾ പ്ലേലിസ്റ്റുകൾ കമ്മ്യൂണിറ്റി പ്ലേലിസ്റ്റുകൾ തിരഞ്ഞെടുത്ത പ്ലേലിസ്റ്റുകൾ നിങ്ങളുടെ ലൈബ്രറിയിൽ നിന്ന് ഇഷ്ടപ്പെട്ട പാട്ടുകൾ ഡൗൺലോഡ് ചെയ്‌ത പാട്ടുകൾ വീണ്ടും ശ്രമിക്കുക റേഡിയോ ഷഫിൾ എഡിറ്റ് ചെയ്യുക റേഡിയോ ആരംഭിക്കുക പ്ലേ ചെയ്യുക അടുത്തത് പ്ലേ ചെയ്യുക ക്യൂവിൽ ചേർക്കുക ലൈബ്രറിയിലേക്ക് ചേർക്കുക ഡൗൺലോഡ് ഡൗൺലോഡ് നീക്കം ചെയ്യുക പ്ലേലിസ്റ്റ് ഇറക്കുമതി ചെയ്യുക പ്ലേലിസ്റ്റിൽ ആഡ് ചെയ്യുക കലാകാരനെ കാണുക ആൽബം കാണുക വീണ്ടെടുക്കുക പങ്കിടുക ഡിലീറ്റ് ചേർത്ത തീയതി പേര് ആർട്ടിസ്റ്റ് വർഷം പാട്ടുകളുടെ എണ്ണം ദൈർഘ്യം ്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി ഗാനം എഡിറ്റ് ചെയ്യുക പാട്ടിന്റെ പേര് പാട്ടുകാരൻ ഗാനത്തിന്റെ പേര് ശൂന്യമാക്കാൻ കഴിയില്ല. പാട്ടുകാരൻ ശൂന്യമായിരിക്കാൻ കഴിയില്ല. സേവ് പ്ലേലിസ്റ്റ് തിരഞ്ഞെടുക്കുക പ്ലേലിസ്റ്റ് എഡിറ്റ് ചെയ്യുക പ്ലേലിസ്റ്റ് സൃഷ്ടിക്കുക പ്ലേലിസ്റ്റ് പേര് പ്ലേലിസ്റ്റിന്റെ പേര് ശൂന്യമാക്കാൻ കഴിയില്ല. എഡിറ്റ് ആർട്ടിസ്റ്റ് കലാകാരന്റെ പേര് കലാകാരന്റെ പേര് ശൂന്യമാക്കാൻ കഴിയില്ല. %d പാട്ട് %d പാട്ടുകൾ %d ആർട്ടിസ്റ്റ് %d ആർട്ടിസ്റ്റുകൾ %d ആൽബം %d ആൽബങ്ങൾ %d പ്ലേലിസ്റ്റ് %d പ്ലേലിസ്റ്റുകൾ പ്ലേലിസ്റ്റ് ഇറക്കുമതി ചെയ്തു മ്യൂസിക് പ്ലെയർ ക്രമീകരണങ്ങൾ രൂപഭംഗി ഡാർക്ക് തീം ഓൺ ഓഫ് സിസ്റ്റം പിന്തുടരുക സ്ഥിര ഓപ്പൺ ടാബ് വരികളുടെ സ്ഥാനം ഇടത് നടുക്ക് വലത് കന്റെന്റ് സ്ഥിര കന്റെന്റ് ഭാഷ സ്ഥിര കന്റെന്റ് രാജ്യം സിസ്റ്റം സ്ഥിരസ്ഥിതി പ്രോക്സി പ്രവർത്തനക്ഷമമാക്കുക പ്രോക്സി തരം പ്രോക്സി URL പ്രാബല്യത്തിൽ വരാൻ പുനരാരംഭിക്കുക പ്ലെയറും ഓഡിയോയും ഓഡിയോ നിലവാരം Auto കൂടി കുറഞ്ഞ് പെർസിസ്റ്റന്റ് കീ ഇക്വലൈസർ പരിധിയില്ലാത്ത ഡൗൺലോഡുകൾ എല്ലാം നീക്കം ചെയ്യുക സ്വകാര്യത തിരയൽ ചരിത്രം താൽക്കാലികമായി നിർത്തുക തിരയൽ ചരിത്രം മായ്‌ക്കുക എല്ലാ തിരയൽ ചരിത്രവും മായ്‌ക്കണമെന്ന് ഉറപ്പാണോ? ബാക്കപ്പും വീണ്ടെടുക്കലും ബാക്കപ്പ് വീണ്ടെടുക്കൽ ബാക്കപ്പ് സൃഷ്‌ടിച്ചു ബാക്കപ്പ് സൃഷ്ടിക്കാൻ കഴിഞ്ഞില്ല ബാക്കപ്പ് പുനഃസ്ഥാപിക്കാൻ കഴിഞ്ഞില്ല കുറിച്ച് അപ്ലിക്കേഷൻ പതിപ്പ് ഇത് നിങ്ങൾ കാണുന്ന ഉള്ളടക്കത്തെ സ്വാധീനിക്കും, ഉദാഹരണത്തിന് നിങ്ങൾ ഒരു പ്രീമിയം അക്കൗണ്ട് ഉപയോഗിച്ച് ലോഗിൻ ചെയ്തിട്ടുണ്ടെങ്കിൽ പ്രീമിയം-മാത്രം ആൽബങ്ങൾ കാണിക്കുന്നു മറന്നുപോയ പ്രിയപ്പെട്ടവ കേള്‍ക്കുന്നത് തുടരൂ നിങ്ങളുടെ യൂട്യൂബ് പ്ലേലിസ്റ്റുകൾ Like എല്ലാം നീക്കം ചെയ്യുക മ്യൂസിക് പ്ലെയർ സാധ്യമെങ്കിൽ, ക്യൂവിന്റെ അവസാനം എത്തുമ്പോൾ കൂടുതൽ പാട്ടുകൾ യാന്ത്രികമായി ചേർക്കുക തിരയൽ ചരിത്രം പകര്‍പ്പുകള്‍ ഒഴിവാക്കുക ഓപ്ഷനുകൾ ഈ ഓപ്ഷൻ ഓണായിരിക്കുമ്പോൾ, സ്ക്രീൻഷോട്ടുകളും സമീപകാലങ്ങളിലെ ആപ്പിന്റെ കാഴ്ചയും പ്രവർത്തനരഹിതമാകും. സ്ഥിരസ്ഥിതി \"%s\" എന്ന പ്ലേലിസ്റ്റ് ഡിലീറ്റ് ചെയ്യാന്‍ നിങ്ങൾ ശരിക്കും ആഗ്രഹിക്കുന്നുണ്ടോ? ആപ്പ് പുനരാരംഭിക്കുമ്പോൾ നിങ്ങളുടെ അവസാന ക്യൂ പുനഃസ്ഥാപിക്കുക ലൈബ്രറി ഗാനങ്ങൾ ഇവിടെ കാണിക്കും ലൈബ്രറി ആർട്ടിസ്റ്റുകൾ ഇവിടെ കാണിക്കും ലൈബ്രറി ആൽബങ്ങൾ ഇവിടെ കാണിക്കും നിങ്ങളുടെ പ്ലേലിസ്റ്റുകൾ ഇവിടെ കാണിക്കും മറ്റ് പതിപ്പുകൾ ഡൗൺലോഡ് ചെയ്ത പാട്ടുകളുടെ സംഭരണത്തിൽ നിന്ന് എല്ലാ \"%s\" പ്ലേലിസ്റ്റ് ഗാനങ്ങളും ഡിലീറ്റ് ചെയ്യണോ? എല്ലാം ലൈബ്രറിയിലേക്ക് ചേർക്കുക ലൈബ്രറിയിൽ നിന്ന് എല്ലാം നീക്കം ചെയ്യുക പ്ലേലിസ്റ്റിൽ നിന്ന് നീക്കം ചെയ്യുക ക്യൂവിൽ നിന്ന് നീക്കം ചെയ്യുക ടെമ്പോയും പിച്ചും പകര്‍പ്പുകള്‍ എന്തായാലും ചേര്‍ക്കുക ആ പാട്ട് നിങ്ങളുടെ പ്ലേലിസ്റ്റിൽ ഉണ്ട് %d പാട്ടുകള്‍ നിങ്ങളുടെ പ്ലേലിസ്റ്റിൽ ഇതിനകം ഉണ്ട് എല്ലാം like ചെയ്യുക തീം പ്ലെയർ ടെക്സ്റ്റ് വിന്യാസം വശത്തേക്ക് പ്ലെയർ സ്ലൈഡർ ശൈലി ഞെരുങ്ങി പലവക ഗ്രിഡ് സെൽ വലുപ്പം ചെറുത് വലുത് Login ചെയ്തിട്ടില്ല ക്യൂ കൂടുതൽ പാട്ടുകൾ സ്വയമേവ ലോഡ് ചെയ്യുക പിശക് സംഭവിക്കുമ്പോൾ അടുത്ത പാട്ടിലേക്ക് യാന്ത്രികമായി പോകുക തുടർച്ചയായ പ്ലേബാക്ക് അനുഭവം ഉറപ്പാക്കുക ടാസ്‌ക് ക്ലിയറാകുമ്പോൾ സംഗീതം നിർത്തുക സ്ക്രീൻഷോട്ട് പ്രവർത്തനരഹിതമാക്കുക അശ്ലീല ഉള്ളടക്കം മറയ്ക്കുക പിരിച്ചുവിടുക പ്രിവ്യൂ Login പരാജയപ്പെട്ടു Logout നിങ്ങളുടെ Discord അക്കൗണ്ടിന്റെ സ്റ്റാറ്റസ് സജ്ജീകരിക്കാൻ Metrolist KizzyRPC ലൈബ്രറി ഉപയോഗിക്കുന്നു. ഇതിൽ Discord ഗേറ്റ്‌വേ കണക്ഷൻ ഉപയോഗിക്കുന്നത് ഉൾപ്പെടുന്നു, ഇത് Discord-ന്റെ TOS-ന്റെ ലംഘനമായി കണക്കാക്കാം. എന്നിരുന്നാലും, ഈ കാരണത്താൽ ഉപയോക്തൃ അക്കൗണ്ടുകൾ താൽക്കാലികമായി നിർത്തിവച്ചതായി അറിയപ്പെടുന്ന കേസുകളൊന്നുമില്ല. നിങ്ങളുടെ സ്വന്തം ഉത്തരവാദിത്തത്തിൽ ഉപയോഗിക്കുക.\n\nMetrolist നിങ്ങളുടെ ടോക്കൺ മാത്രമേ എക്‌സ്‌ട്രാക്‌റ്റ് ചെയ്യുകയുള്ളൂ, മറ്റെല്ലാം പ്രാദേശികമായി സംഭരിക്കപ്പെടും. ചരിത്രം സ്റ്റാറ്റസ് സംഗീത വിഭാഗങ്ങൾ അക്കൗണ്ട് തിരഞ്ഞെടുത്തവ ക്വിക്ക് പിക്‌സ് ജനറേറ്റ് ചെയ്യാൻ വേണ്ടി പാട്ടുകൾ കേൾക്കുക സമാനമായത് പുതുതായി റിലീസ് ആയ ആൽബങ്ങൾ ഇന്ന് ഇന്നലെ ഈ ആഴ്ച കഴിഞ്ഞ ആഴ്ച കൂടുതൽ കേട്ട പാട്ടുകൾ കൂടുതൽ കേട്ട കലാകാരൻമാർ കൂടുതൽ കേട്ട ആൽബങ്ങൾ ലൈബ്രറി ഇഷ്ടപ്പെട്ടത് ഡൌൺലോഡ് ചെയ്തത് മാർക്ക് ചെയ്തവ ഒന്നും കണ്ടെത്താനായില്ല ഇതിൽ ഒന്നുമില്ല റീസെറ്റ് വിശദാംശങ്ങൾ ലൈബ്രറിയിൽ നിന്ന് കളയുക ചരിത്രത്തിൽ നിന്ന് കളയുക ഡൌൺലോഡ് ആകുന്നു ഓൺലൈനിൽ തിരയുക സിങ്ക് ചെയ്യുക കൂടുതൽ കാര്യങ്ങൾ കളി സമയം ഇഷ്ടാനുസൃത ക്രമം മീഡിയ ഐഡി MIME തരം കോഡക്സ് ബിറ്റ് റേറ്റ് സാമ്പിൾ റേറ്റ് ശബ്ദ തീവ്രത ശബ്ദം ഫയലിന്റെ വലിപ്പം അറിയാത്തവ വരികൾ മാറ്റം വരുത്തുക വരികൾ തിരയുക %d ആഴ്ച %d ആഴ്ചകൾ %d മാസം %d മാസങ്ങൾ %d വർഷം %d വർഷങ്ങൾ പ്ലേയ്‌ലിസ്റ്റിൽ നിന്ന് \"%s\" കളയുക പ്ലേലിസ്റ്റ് സിങ്ക് ചെയ്തു പഴയപടിയാക്കുക വരികൾ കണ്ടുപിടിക്കാനായില്ല ഉറക്കസമയം പാട്ടിന്റെ അവസാനം 1 മിനിറ്റ് %d മിനിറ്റുകൾ ഒരു സ്ട്രീമും ലഭ്യമല്ല നെറ്റ്‌വർക്ക് കണക്ഷൻ ഇല്ല ടൈം ഔട്ട് എന്തോ പ്രെശ്നം പറ്റി ലൈക്ക് ലൈക്ക് കളയുക ഷഫിൾ ഓൺ ഷഫിൾ ഓഫ് റിപീറ്റ് മോഡ് ഓഫ് ഈ പാട്ട് റിപീറ്റ് ചെയ്യുക ക്യൂ റിപീറ്റ് ചെയ്യുക എല്ലാ പാട്ടുകളും തിരഞ്ഞ പാട്ടുകൾ ഡൈനാമിക് തീം ഓൺ ആക്കുക ശുദ്ധ കറുപ്പ് നാവിഗേഷൻ ടാബിൽ മാറ്റം വരുത്തുക ലോഗ് ഇൻ ലോഗിൻ നിശബ്ദത ഒഴിവാക്കുക ശബ്ദ സാധാരണവൽക്കരണം സ്റ്റോറേജ് കാച്ഛ് ചിത്രത്തിന്റെ കാച്ഛ് പാട്ടിന്റെ കാച്ഛ് പരമാവധി കാച്ഛ് വലുപ്പം പരമാവധി ചിത്രത്തിന്റെ കാച്ഛ് വലുപ്പം ചിത്രത്തിന്റെ കാച്ഛ് കളയുക പരമാവധി പാട്ടിന്റെ കാച്ഛ് വലുപ്പം പാട്ടിന്റെ കാച്ഛ് കളയുക %s ഉപയോഗിച്ചു കേട്ടതിന്റെ ചരിത്രം കേട്ടതിന്റെ ചരിത്രം നിർത്തുക കേട്ടതിന്റെ ചരിത്രം കളയുക എല്ലാ കേട്ട ചരിത്രവും കളയണമെന്ന് നിങ്ങൾക്ക് ഉറപ്പാണോ? കണ്ടെന്റ് കാണുവാൻ ലോഗിൻ ചെയ്യുക LrcLib വരികൾ ഓൺ ആക്കുക KuGou വരികൾ ഓൺ ആക്കുക കൊണ്ടുവന്ന പ്ലേലിസ്റ്റ് Discord യോജിപ്പിക്കുക റിച്ഛ് പ്രെസെൻസ് ഓൺ ആക്കുക പുതിയ വേർഷൻ ലഭ്യമാണ് വിവർത്തന പദ്ധതികൾ വിവർത്തന പദ്ധതികൾ കളയുക ================================================ FILE: app/src/main/res/values-ms/metrolist_strings.xml ================================================ Lokal Jauh Carta Kembali Muka album Video muzik teratas Trending Mingguan Bulanan Tahunan Bersambungan Disukai Dimuat turun Teratas saya Simpanan sementara Dimuat naik Dimuat naik Segerak senarai main Senarai main ditutup Nota: Ia membolehkan penyegerakkan dengan YouTube Music. Ia TIDAK boleh ditukar kelak. Menjana imej Sila tunggu Batal Kongsi lirik Kongsi sebagai teks Kongsi sebagai imej Had pilihan maksimum Kongsi yang dipilih Sesuaikan warna Warna teks Warna kedua teks Warna latar Dibuang dari simpanan sementara Muat turun semua lagu untuk dimainkan di luar talian Buang semua lagu yang dimuat turun dari senarai main Memuat turun Kongsi senarai main dengan yang lain Buang senarai main selamanya Segerakkan senarai main dengan YouTube Music Salin pautan Pilih semua Suka semua Nyahsuka semua Tarikh dikemaskini Pautan disalin ke clipboard Memulakan radio Dimainkan Sekarang Lirik Tutup Sorok Muka Pemain Tukar rajah album dengan logo aplikasi pada pemain Yang ada di dalam senarai main: %d kali %1$d saat ke depan %1$d saat ke belakang Langkau secara progresif Jika dibuka, 5 saat tambahan akan ditambah pada setiap langkauan progresif (progressive seek) Kandungan seerti Rupa latar pemain Ikut tema Kecerunan Reka bentuk pemain baru Reka bentuk pemain mini baharu Kabur Warna butang pemain Asal Warna utama Warna tertiari ================================================ FILE: app/src/main/res/values-nb-rNO/metrolist_strings.xml ================================================ Avbryt Del som tekst Del som bilde Lenke kopiert til utklippstavlen Klikk for å vise tokenen Sveip for å bytte sanger Dette er en AVANSERT innloggingsmetode. Som alternativ til nettportalen, kan du direkte legge inn eller oppdatere innloggingstokenen din her. Dette kan f.eks. gjøre innlogging på flere enheter raskere. NB: Ugyldige tokenformater som appen ikke lykkes til i å tolke, vil ikke godtas Sangtekster NB: Dette tillater synkronisering med YouTube Music. Dette kan ikke endres senere. Del sangtekster Lignende innhold Lokal Synkronisering deaktivert Fjern Hitlister Tilbake Albumomslag Topp musikkvideoer Trendende Uker Måneder År Kontinuerlig Nedlastet Likt Mine topp Hurtiglagret Synkroniser spilleliste Skaper bilde Vennligst vent Øvre grense for utvalg Del valgte Tilpass farger Tekstfarge Sekundær tekstfarge Bakgrunnsfarge Fjern fra hurtiglagring Kopier lenke Velg alt Lik alt Mislik alt Dato oppdatert Allerede i spilleliste: %d gang %d ganger Bakgrunnsstil for avspilleren Følg tema Gradient Uskarp Farger på avspillerknapper Standard Sveip sangen til venstre for å legge den til i køen, eller til venstre for å legge til neste Endre sangtekster ved klikk Smal Skjul etiketter på bunnfeltsnavigasjonen Automatiske spillelister Vis «Likt»-spilleliste Vis «Nedlastet»-spilleliste Vis «Topp»-spilleliste Vis «Hurtiglagret»-spilleliste Avansert innlogging (med token) Klikk igjen for å kopiere eller redigere tokenen Alminnelig Proxy Endre standardvisning i biblioteket Still inn hurtigvalg Basert på siste sang spilt av Appspråk Opplastet Opplastet Om artisten Vis mer Vis mindre Artistside Vis artistbeskrivelse Vis antall abonnementer Vis månedlige lyttere Last ned alle sanger for lytting uten nett Fjern alle nedlastede sanger fra denne spillelisten Nedlasting er underveis Del denne spillelisten med andre Fjern denne spillelisten permanent Synkroniser spilleliste med YouTube Music Starter radio Spiller nå Lukk Vis miniatyrbilde i spilleren Erstatt albumcover med applogo i spilleren Beskjær albumcover Tving et kvadratisk visningsforhold ved å beskjære miniatyrbilder av videoer %1$d sekunder forover -%1$d sekunder bakover Progressiv spoling Dersom aktivert legges 5 ekstra sekunder til inkrementalt for hvert hopp Nytt spillerdesign Ny minispillerdesign Primær farge Tertiær farge Bølgete Sveip sangen for å fjerne den fra spillelisten Automatisk rullende tekst Aktiver glødende tekst-effekt Legg til glødende animasjon og sprett-effekt til gjeldende sangtekst Aktiver Better Lyrics Bruk Better Lyrics-tjeneren til ord-for-ord sangtekster Aktiver SimpMusic sangtekster Bruk SimpMusic Lyrics-tjeneren for synkroniserte sangtekster Re-synk Vis \"Opplastet\" spilleliste Shuffle spilleliste/album først Når du shuffler, spill alle sanger fra den originale spillelisten/albumet først, så lignende innhold Vis Wrapped-kort Hopp forbi stille deler av sanger Hopp over stillhet øyeblikkelig ================================================ FILE: app/src/main/res/values-nb-rNO/strings.xml ================================================ Historie Hjem Sanger Album Spillelister %d valgt %d valgt Statistikk Stemning og sjangrer Hurtigvalg Hør på sanger for å generere hurtigvalgene dine YouTube-spillelistene dine Ligner på Nylig slupne album I dag I går Denne uken Forrige uke Mest spilte sanger Mest spilte artister Mest spilte album Søk Søk gjennom YouTube Music … Søk gjennom biblioteket … Bibliotek Likte Nedlastede Alle Videoer Album Spillelister Gemenskapsspillelister Utvalgte spillelister Bokmerkede Spillelistene dine skal dukke opp her Fra biblioteket ditt Vil du virkelig fjerne spillelisten «%s»? Prøv på nytt Radio Omstokk Tilbakestill Detaljer Rediger Spill Legg til alle i biblioteket Fjern fra biblioteket Fjern alle fra biblioteket Last ned Laster ned Fjern nedlasting Importer spilleliste Vis artist Vis album Hent inn på ny Del Fjern Fjern fra historikk Fjern fra spilleliste Fjern fra køen Søk på nettet Synkroniser Avansert Tempo og tonehøyde Dato tillagt Navn Artist År Sangantall Lengde Media-ID MIME-type Kodeker Bitrate Samplerate Velg spilleliste Rediger spilleliste Opprett spilleliste Rediger artist Artistnavn Artistnavnet kan ikke være tomt. Legg til allikevel Sangen er allerede i spillelisten %d sanger er allerede i spillelisten %d sang %d sanger %d artist %d artister Spilleliste importert %d spilleliste %d spillelister Fjernet «%s» fra spilleliste Spilleliste synkronisert Angre Sangtekster funnet ikke Søvntidsur Ingen strømmer tilgjengelig Ingen nettverktilkobling Tidsavbrudd Lik Fjern like Fjern alle liker Gjentar én sang Gjentar køen Alle sanger Søkte sanger Musikkspiller Innstillinger Utseende Mørk Følg systemet Ren svart Tilpass navigasjonsfaner Spiller Spiller-tekstjustering På siden Senter Høyre Forvalg Ymse Forvalgsfane Rutenettscellestørrelse Liten Innhold Logg inn Ikke innlogget Forvalginnholdspråk Systemforvalg Slå på mellomtjener Type Nettadresse Start på ny for iføre endringer Spiller og lyd Lydkvalitet Automatisk Høy Vedvarende kø Gjenopprett køen din når programmet starter Last inn flere sanger automatisk Legg til flere sanger automatisk når køens slutt er nådd, om mulig Hopp over stillhet Sørger for at avspillingen er fortløpende Stopp musikk når programmet dras bort Tonekontroll Hurtiglager Bildehurtiglager Maksimal hurtiglagringsstørrelse %s brukt Personvern Lyttehistorikk Fjern søkehistorikk Skru av skjermavbildninger Når dette er aktivert, kan skjermavbildninger ikke utføres i programmet, og «Nylig» i programmet er avslått Slå på LrcLib-sangteksttilbyder Slå på KuGou-sangteksttilbyder Sikkerhetskopier Discord-integrasjon Avvis Innstillinger Innlogging mislyktes Slå på rik tilstedeværelse Programversjon Ny versjon tilgjengelig Oversettingsmodeller Fjern oversettingsmodeller %d uke %d uker %d måned %d måneder 1 minutt %d minutt Artister Konto Ingen treff funnet Bibliotekssanger dukker opp her Biblioteksartister dukker opp her Glemte favoritter Fortsett å lytte Sanger Artister Biblioteksalbum dukker opp her Andre utgaver Likte sanger Nedlastede sanger Spillelisten er tom Vil du virkelig fjerne alle «%s» spillelistesanger fra lagringen av nedlastede sanger? Start radio Legg til i køen Legg til i biblioteket Spill neste Legg til i spilleliste Spilletid Egendefinert rekkefølge Lydintensitet Lydstyrke Ukjent Rediger sangtekster Filstørrelse Kopiert til utklippstavlen Let etter sangtekster Rediger sang Sangartister Sangtittel Spillelistenavn Spillelistenavnet kan ikke være tomt. Sangtittelen kan ikke være tom. Hopp over duplikater Sangartistene kan ikke være tom. Lagre Duplikater %d album %d album %d år %d år Slutten av sangen Ukjent feil Lik alle Slå på dynamisk drakt Omstokking på Omstokking av Gjentagelse av Drakt Av Sangtekstjustering Venstre Spiller-glidebryterstil Snirklete Stor Forvalginnholdsland Lav Hopp til neste sang automatisk når en feil oppstår Lydnormalisering Sanghurtiglagring Lagring Ubegrenset Fjern lyttehistorikk Fjern lyttehistorikk? Søkehistorikk Fjern alle nedlastinger Maksimal bildehurtiglagringsstørrelse Ikke lagre søkehistorikk Fjern bildehurtiglager Maksimal sanghurtiglagerstørrelse Ikke lagre lyttehistorikk Fjern søkehistorikken din? Fjern sanghurtiglager Skjul eksplisitt innhold Sikkerhetskopiering og gjenoppretting Sikkerhetskopi opprettet Kunne ikke opprette sikkerhetskopi Kunne ikke utføre gjenoppretting Gjenopprett Spilleliste importert Logg ut Metrolist bruker KizzyRPC for å stille inn statusen på Discord-kontoen din. Dette innebærer bruk ave Discord Gateway-portneren, som kan anses som et brudd på Discords tjenestevilkår. Det finnes dog ingen kjente tilfelle av suspenderte brukerkonter på denne grunnen. Bruk på egen risiko. \n \nMetrolist henter bare inn symbolet ditt, og alt annet blir lagret lokalt. Forhåndsvisning Om ================================================ FILE: app/src/main/res/values-night/colors.xml ================================================ #E6E1E5 #CAC4D0 ================================================ FILE: app/src/main/res/values-night/widget_colors.xml ================================================ #4F378B #EADDFF #633B48 #FFD8E4 @color/widget_primary_container @color/widget_on_primary_container #9A82DB #99EADDFF #66EADDFF #33EADDFF #1AEADDFF ================================================ FILE: app/src/main/res/values-night-v31/widget_colors.xml ================================================ @android:color/system_accent1_700 @android:color/system_accent1_100 @android:color/system_accent3_700 @android:color/system_accent3_100 @color/widget_primary_container @color/widget_on_primary_container ================================================ FILE: app/src/main/res/values-nl/metrolist_strings.xml ================================================ Hitlijsten Terug Albumhoes Trending Weken Maanden Jaren Voortdurend Nummers die je leuk vindt Gedownload Meest geluisterd Gecached Playlist synchroniseren Synchroniseren uitgeschakeld Opmerking: Dit activeert synchronisatie met YouTube Music. Dit kan later NIET worden gewijzigd. Afbeelding genereren Een ogenblik geduld Annuleren Songtekst delen Delen als tekst Delen als afbeelding Max. selectie limiet Deel geselecteerden Kleuren personaliseren Tekstkleur Secundaire tekstkleur Achtergrondkleur Beste muziek videos Alles selecteren Alles leuk vinden Alles niet leuk vinden Datum gewijzigd Link gekopieerd naar het klembord Songtekst Al in afspeellijst: %d keer %d keren Speler achtergrond stijl Volg thema Gradient Standaard Login met token Klik om token te tonen Klik opnieuw om te kopiëren of te wijzigen Algemeen Proxy 1 seconde %d seconden Lokaal Afstandsbediening Geüpload Geüpload Verwijder van cache Kopieer link Radio starten Afsluiten Gelijkaardige inhoud Vervagen Kleuren spelerknoppen Vegen om lied te veranderen Naar links vegen om lied in de afspeellijst te zetten of naar rechts om als volgende te spelen Naar tekst springen bij klikken Dun Compacte hoofdnavigatiebalk Automatische playlists \"Nummers die je leuk vindt\" tonen \"Gedownload\" tonen \"Meest geluisterd\" tonen \"Gecached\" tonen Gebaseerd op laatst geluisterd nummer App taal Vergelijkbare muziek aanzetten Automatisch vergelijkbare nummers toevoegen wanneer het einde van de afspeellijst is bereikt Automatisch downloaden bij het liken Weet je zeker dat je alle gecached nummers wilt wissen? Weet je zeker dat je alle downloads wilt wissen? Niet op YouTube ingelogd Open ondersteunde links Kon app instellingen niet openen Uitgaven informatie Afgelopen 24 uur Afgelopen week Afgelopen maand Afgelopen jaar Meest geluisterd lengte Geschiedenis duur Informatie Omschrijving Weergaven Automatisch songtekst scrollen Japans romaniseren Koreaans romaniseren Account automatisch synchroniseren Meer inhoud Nieuw speler ontwerp Mini speler swipe gevoeligheid Weet je zeker dat je gecached afbeeldingen wilt wissen? Uitschakelen Abonneer Geabonneerd Nieuw mini-speler ontwerp Nu afspelen +%1$d seconden vooruit -%1$d seconden achteruit Vervang album afbeelding met de app logo in de speler Privacy & Beveiliging Speler & Inhoud Opslag & Gegevens Systeem & Over Proxy configureren Proxy gebruikersnaam Proxy wachtwoord Authenticatie aanzetten Romanisatie Songtekst romanizatie Russisch romaniseren Oekraïens romaniseren Wit-Russisch romaniseren Kirgizisch romaniseren Servisch romaniseren Bulgaars romaniseren EXPERIMENTEEL: Taal per regel detecteren Weet je dit zeker? Huidig nummer romaniseren Afspeellijst omslag aanpassen Opmerking: Je account moet aan een telefoonnummer gekoppeld zijn en geverifieerd zijn op YouTube Music om de afspeellijstcover te kunnen wijzigen. Aanpassen van omslag kan enkele momenten duren. Uit bibliotheek kiezen Verwijder aangepaste afbeelding \"Geupload\" tonen Macedonisch romaniseren Updateprogramma Automatisch voor updates controleren Update meldingen aanzetten Update beschikbaar Integraties Gebruikersnaam Wachtwoord Last.fm Integratie Scrobbling aanzetten Scrobbling Configuratie Chinees romaniseren Video nummers verbergen Primaire kleur Synchroniseren Nummer informatie Titel of artiest veranderen Opslaan in bibliotheek Beschikbaar maken voor offline afspelen Toevoegen aan afspeellijst Opnieuw YouTube Music metadata ophalen Nummer tempo en toonhoogte aanpassen Audio equalizer aanpassen Dynamisch app pictogram Mini-speler OLED mini-speler Tertiaire kleur Inloggen… Verwijder alle gedownloaden nummers van deze afspeellijst Downloaden wordt uitgevoerd Deel afspeellijst Afspeellijst verwijderen Synchroniseer afspeellijst met YouTube Music Songtekst lettergrootte Songtekst regel afstand Toon Wrapped Vooruitgang %s%% Titel gekopieerd Artiest gekopieerd Gloeiende songtekst Alle nummers downloaden voor offline afspelen Swipe nummer om het van de afspeellijst te verwijderen Over Meer tonen Minder tonen Artiesten pagina Artiesten beschrijving tonen Aantal abonnees tonen Maandelijkse luisteraars tonen Verberg album afbeelding in speler Muziek progressief doorzoeken Golvend voegt 5 seconden toe bij elke skip Verander standaard bibliotheek chip Importeer een \"m3u\" afspeellijst Importeer een \"csv\" afspeellijst Better Lyrics Gebruik de Better Lyrics provider voor woord-voor-woord gesynchroniseerde songtekst Cyrillisch De Cyrillische taal zal lijn voor lijn gedetecteerd worden in plaats van het hele lied. Snelle keuzes instellen Afspelen/Pauzeer Woord-voor-woord animatie stijl Voegt gloeiende animatie en stuiter effecten toe aan actieve songtekst Geen Vervagen Gloei Schuiven Albumhoes bijsnijden SimpMusic Lyrics aanzetten Behoud de shuffle- en herhaalmodus Behoud de shuffle- en herhaalmodus bij het opnieuw opstarten van de app Dwing een vierkante beeldverhouding door videominiaturen bij te snijden Gebruik SimpMusic songtekst provider voor gesynchroniseerde teksten Stilte direct overslaan Spoel tijdens stille momenten vooruit in plaats van het afspelen te versnellen Toon de titel van het nummer duidelijk in plaats van de naam van de artiest %d%% Opmerking: het toevoegen van lokale nummers aan gesynchroniseerde/externe afspeellijsten wordt niet ondersteund. Alle andere combinaties zijn geldig Download automatisch nummers die je leuk vindt Altijd Vind-ik-leuks Niet leuk Muziek pauzeren wanneer media gedempt is Houd scherm aan als mediaspeler is uitgeklapt Meldingen over nieuwe versies Google Casten Voeg toe aan het begin van je wachtrij Voeg toe aan het einde van je wachtrij Deel een link naar dit item Dit item permanent verwijderen ================================================ FILE: app/src/main/res/values-nl/strings.xml ================================================ Thuis Nummers Artiesten Albums Afspeellijsten %d geselecteerd %d geselecteerd Geschiedenis Statistieken Stemmingen en genres Account Snelle keuzes Beluister een aantal nummers om je snelle keuzes te genereren Nieuwe albums Vandaag Gisteren Deze week Vorige week Meest afgespeelde nummers Meest afgespeelde artiesten Meest afgespeelde artiesten Zoeken Zoeken via YouTube Music… Zoeken in bibliotheek… Bibliotheek Geliked Gedownload Alles Nummers Video\'s Albums Artiesten Afspeellijsten Afspeellijsten van de community Voorgestelde afspeellijsten Gebookmarked Geen resultaten gevonden Verwijderen van bibliotheek Favoriete nummers Gedownloade nummers Afspeellijst is leeg Opnieuw Radio Schuifelen Resetten Details Bewerken Radio starten Afspelen Volgende afspelen Voeg toe aan wachtrij Voeg toe aan bibliotheek Verwijderen van bibliotheek Downloaden Wordt gedownload Verwijder download Importeer afspeellijst Voeg toe aan afspeellijst Toon artiest Toon album Ververs Delen Verwijderen Verwijder uit geschiedenis Zoek online Synchroniseer Geavanceerd Datum toegevoegd Naam Artiest Jaar Aantal nummers Duur Speeltijd Aangepaste volgorde Media-id MIME-type Codecs Bitsnelheid Voorbeeldfrequentie Luidheid Volume Bestandsgrootte Onbekend Gekopieerd naar klembord Bewerk songtekst Zoek naar songtekst Bewerk nummer Titel nummer Artiesten nummer Titel van nummer mag niet leeg zijn. Artiest kan niet leeg zijn. Opslaan Kies afspeellijst Bewerk afspeellijst Afspeellijst maken Naam van afspeellijst Afspeellijstnaam mag niet leeg zijn. Bewerk artiest Artiest naam Artiestennaam mag niet leeg zijn. %d nummer %d nummers %d artiest %d artiesten %d album %d albums %d afspeellijst %d afspeellijsten %d week %d weken %d maand %d maanden %d jaar %d jaren Afspeellijst geimporteerd \"%s\" van afspeelijst verwijderd Afspeellijst gesynchroniseerd Ongedaan maken Songtekst niet gevonden Slaap timer Einde van nummer 1 minuut %d minuten Geen stream beschikbaar Geen netwerkverbinding Time-out Onbekende fout Leuk vinden Verwijder like Schuifelen aan Schuifelen uit Herhaalmodus uit Huidig nummer herhalen Wachtrij herhalen Alle nummers Opgezochte nummers Muziekspeler Instellingen Uiterlijk Dynamisch thema inschakelen Donker thema Aan Uit Volg systeem Zuiver zwart Standaard tabblad Pas tabbladen aan Songtekst positie Links Midden Rechts Inhoud Inloggen Standaard inhoudstaal Standaard inhoudsland Systeemstandaard Proxy inschakelen Proxy-type Proxy-URL Herstart om effect te hebben Speler en geluid Geluidskwaliteit Automatisch Hoog Laag Blijvende wachtrij Sla sliltes over Geluidsnormalisatie Egalisator Opslag Cache Afbeeldingen Cache Nummer Cache Maximale cache grootte Ongelimiteerd Wis alle downloads Max. grootte cache voor afbeeldingen Wis afbeeldingen cache Max. grootte cache voor nummers Wis nummer cache %s gebruikt Privéheid Pauzeer luistergeschiedenis Luistergeschiedenis wissen Weet je zeker dat je de luistergeschiedenis wil wissen? Pauzeer zoekgeschiedenis Zoekgeschiedenis wissen Weet je zeker dat je de zoekgeschiedenis wil wissen? Schakel KuGou songtekst provider in Backup en herstel Reservekopie Herstellen Afspeellijst geimporteerd Reservekopie succesvol gemaakt Reservekopie maken mislukt Reservekopie herstellen mislukt Over App versie Nieuwe versie beschikbaar Vertaalmodellen Verwijder vertaalmodellen LrcLib tekstaanbieder inschakelen Stop muziek op \'taak wissen\' Afwijzen Tempo en toonhoogte Voeg indien mogelijk automatisch meer nummers toe wanneer het einde van de wachtrij is bereikt Vergeten favorieten Blijf luisteren Je YouTube-afspeellijsten Vergelijkbaar met Je afspeellijsten worden hier weergegeven Bibliotheeknummers worden hier weergegeven Bibliotheekartiesten worden hier weergegeven Bibliotheekalbums worden hier weergegeven Andere versies Wilt u echt alle nummers van de afspeellijst “%s” verwijderen uit de opslag Gedownloade nummers? Wil je echt de afspeellijst “%s” verwijderen? Alles toevoegen aan bibliotheek Alles verwijderen uit bibliotheek Uit afspeellijst verwijderen Uit wachtrij verwijderen Duplicaten Duplicaten overslaan Toch toevoegen Het nummer staat al in je afspeellijst %d nummers staan al in je afspeellijst Alle leuk vinden Alle \'vind ik leuks\' verwijderen Thema Speler Tekstuitlijning van speler Zijdig Speler schuifregelaar-stijl Standaard Kronkelend Overige Rastercelgrootte Klein Groot Niet ingelogd Wachtrij Herstel je laatste wachtrij wanneer de app start Automatisch meer nummers laden Automatisch overspringen naar het volgende nummer bij een fout Zorg voor een continue afspeelervaring Luistergeschiedenis Zoekgeschiedenis Schermafbeelding uitschakelen Als deze optie is ingeschakeld, zijn schermafbeeldingen en de weergave van de app in Recente bestanden uitgeschakeld. Expliciete inhoud verbergen Discord integratie Metrolist gebruikt de KizzyRPC bibliotheek om de status van je Discord account in te stellen. Dit omvat het gebruik van de Discord Gateway verbinding, die kan worden beschouwd als een schending van de TOS van Discord. Er zijn echter geen gevallen bekend van gebruikersaccounts die om deze reden zijn geschorst. Gebruik op eigen risico.\n\nMetrolist zal alleen uw token uitpakken, en al het andere wordt lokaal opgeslagen. Opties Voorbeeld Aanmelden mislukt Afmelden Rijke aanwezigheid inschakelen Dit kan invloed hebben op welke inhoud u ziet en laat bijvoorbeeld alleen premium albums zien als u bent ingelogd met een Premium account Gebruik login om inhoud te bekijken Inloggen ================================================ FILE: app/src/main/res/values-nn/metrolist_strings.xml ================================================ Eining Tenar Hittlister Far atter Albumomslag Populære musikkvideoar Trendande Veker Månader År Uavbroten Lika Nedladde Mine topp Snarlagra Oppladde Oppladde Synkroniser speleliste Synkronisering deaktivert Obs! Dette tillèt synkronisering med YouTube Music og kan IKKJE brigdast seinare. Skapar bilete Venlegast vent Bryt av Del songtekster Del som tekst Del som bilete Øvre grense for utval Del valde Tilpass leter Tekstlet Sekundær tekstlet Bakgrunnslet Tak bort frå snarlagring Kopier lenkje Vel alle Lik alle Mislik alle Dato oppdatert Lenkje kopiert til utklippstavla Påbyrjar radio Spelar no Songtekster Lat att Gøym avspelarminiatyrbilete Byt ut albumkunst med appmerket i spelar Alt i speleliste: %d gong %d gonger %1$d sekund framover %1$d sekund attover Progressiv søking Dreg på seg 5 sekund i tillegg på kvar einskild søkingsframspoling om påslegen Liknande innhald Bakgrunnsstil for avspelar Fylg tema Letovergang Ny spelardesign Ny minispelardesign Uskarpleik Let på avspelarknappar Standard Sveip for å byta songar Sveip songen åt venstre for å leggja han til i køen eller til høgre for å spela han nest Sveip songen for å taka han bort frå spelelista Brigd songtekster ved klikk Skroll songtekster sjølvverkande Smal Smalt botnnavigeringsfelt Automatiske spelelister Syn «Lika»-spelelista Syn «Nedladde»-spelelista Syn «Mine topp»-spelelista Syn «Snarlagra»-spelelista Syn «Oppladde»-spelelista Logg inn med lykel Trykk for å syna lykelen Trykk att for å kopiera eller brigda Dette er ein AVANSERT innloggingsmetode. Som alternativ til nettportalen, kan du direkte leggja inn eller oppdatera innloggingslykelen din (innloggings-«token») her. Dette kan t.d. gjera det å logga inn på fleire einingar snøggare. NB: Ugilde lykelformat som appen ikkje lukkast i å tolka, kjem ikkje til å verta godtekne Automatisk synkronisering med konto Meir innhald Brigd spelelisteomslag NB: Kontoen din lyt vera lenkt til eit telefonnummer og stadfest på YouTube Music for å brigda spelelisteomslaget. Etter at du vel eit bilete, dukkar det nye omslaget på spelelista opp om eit bel. Venlegast vent imedan dette hender. Vel frå samling Fjern eigendefinert bilete Ålment Proxytenar Brigd standardvisning i samlinga Still inn snarval Basert på siste song høyrd på Appmål Konfigurer proxytenar Proxytenarbrukarnamn Proxytenarpassord Slå på autentisering Nøyt detaljar i staden for ein tilstand Syn songtittel fremst i staden for artistnamn Slå på liknande innhald Legg automatisk til fleire liknande songar når køen tek slutt %d %% Importer m3u-speleliste Importer csv-speleliste Lad ned alle songar for fråkopla avspeling Tak alle nedladde songar bort frå denne spelelista Nedlading er i gang Del denne spelelista med andre Slett denne spelelista for æve Synkroniser spelelista med YouTube Music Hovudlet Tredjelet Glødande songtekster Legg glød og sprett på den noverande songteksta Slå på Better Lyrics Nøyt Better Lyrics-tilbydaren for ord-etter-ord synkroniserte songtekster Attsynkroniser Blanda speleliste/album fyrst Når du blandar, skal alle songar frå den opphavlege spelelista/album verta spela av fyrst, og så liknande innhald Syn Wrapped-kort Obs! Det er ikkje støtt å leggja lokale songar inn i synkroniserte/fjerre spelelister. Alle andre samanblandingar funkar Lad sjølvverkande ned ved å lika Lad sjølvverkande ned songar når du likar dei Minispelarsveipsfølsemd %1$d %% Er du viss på at du vil reinsa alle hurtiglagra songar? Er du viss på at du vil reinsa alle hurtiglagra bilete? Er du viss på at du vil reinsa alle nedladingar? Slå av Ikkje logga på YouTube Opna stødde lenkjer Klarte ikkje å opna applikasjonsinnstillingar Sleppsnotat All æve Siste døger Siste veke Siste månad Siste år Lengd på Mine topp-lista Sogelengd Opplysingar Skildring Syningar Likar Mislikar Abonner Abonnert 1 sekund %d sekund Ikkje lad inn fleire når alt tvitekst Ikkje lad sjølvverkande inn fleire songar og liknande innhald når tvitek alt-modus er påslege Kyrillisk Romanisering Romanisering av songtekster Romaniser japanske songtekster Romaniser koreanske songtekster Romaniser kinesiske songtekster Romaniser ryske songtekster Romaniser ukrainske songtekster Romaniser kviteryske songtekster Romaniser kirgisiske songtekster Romaniser serbiske songtekster Romaniser bulgarske songtekster EKSPERIMENTELT: Kjenn att mål line etter line Målet som nøyter det kyrilliske alfabetet, skal kjennast att line etter line i staden for i songen som ein heilskap. Er du viss? Om Syn meir Syn mindre Artistside Syn artistskildring Syn abonnementstal Syn månadlege lydarar Snirklete Slå på songtekster frå SimpMusic Nøyt songtekster frå SimpMusic-tilbydaren for synkroniserte songtekster Hoppa fram gjennom ljodlause luter av songar Hoppa over togn momentant Hoppa fram under togn i songen i staden for å auka avspelingsfarta Hugs blanding og tvitaking Hugs blandings- og tvitakingsmodus når appen vert omstarta Pausa musikk når medium er dempa Dette er ein funksjon som ender og då kan mislukkast.\n\nSom standard er mål avgjort frå heile songen, men med denne funksjonen vert målet avgjort line etter line. Dette tillèt at fleirmålssongar funkar, MEN det kan hende at målet ikkje alltid er rett (t.d. om det er ei ukrainsk songtekst som ikkje inneheld bokstavar som einast er i ukrainsk, så kan det hende at ho vert romanisert som russisk).\n\nDersom du ikkje har lyte, råder vi til å halde denne funksjonen slegen av. Romaniser noverande song Songtekstsforskuving Grensesned Personvern og tryggleik Avspelar og innhald Lagring og data System og opplysingar Oppdaterar Sjekka etter oppdateringar sjølvverkande Slå på oppdateringsvarsel Oppdatering tilgjengeleg Appoppdateringar Varsel om nye utgåver Slå på avlading Nøyte avladingsljodstigen («offload audio path») for audioavspeling. Å slå dette av kan auka straumforbruk, men kan gagna om du opplever lyte med audioavspeling eller etterhandsaming Google Cast Slå på det å kasta audio til Chromecast og andre Cast-kunnige einingar Romaniser makedonske songtekster Hopflettingar Nøytarnamn Passord Hopfletting med Last.fm Slå på scrobbling Send «Spelar no» Send likar/mislikar Elska/avelska songar i Last.fm når dei vert lika/mislika i Metrolist Loggar inn… Scrobblingsoppset Scrobbla songar lengre en Skrobblingsforseinking i prosent Skrobblingsforseinking i minutt Gøym videosongar Syn songopplysingar Brigda tittel eller artist Oppretta ein stasjon utifrå denne songen Legg til øvst i køen Legg til nedst i køen Lagra i biblioteket ditt Gjer tilgjengeleg for fråkopla avspeling Legg til i ei av dine spelelister Henta dei siste metadataa frå YouTube Music Del ei lenkje til denne songen Tak bort denne songen permanent Brigd snøggleiken og tonehøgd på songen Juster audioutjamnaren Slå på dynamisk ikon Minispelar Reinsvart minispelar Vent no då! Du har valt ei snarlagringsstorleiksgrense mindre en storleiken på snarlagringa nett no (%1$s). Dersom du held fram, kan appen taka bort somme snarlagra %2$s for å høva til den nye grensa. Hald fram likevel? Hald fram Ord-etter-ord-animeringsstil Ingen Avbleiking Lysing Skliding Karaoke Apple Music Tekststorleik på songtekster Lineavstand på songtekster Albumkunst for %s Du har høyrt på ulike album Toppalbumet ditt er Di personlege speleliste er klar Dine toppalbum i år Du har høyrt på dette albumet i %d minutt %d minutt Ingen data Dine toppartistar i år %d minutt Dine toppsongar i år Albumkunst Toppartisten din i år er Toppartistbilete Du har høyrt på hen i %d minutt Din mest spela song er Du har høyrt på i %d minutt Du høyrde på ulike artistar Du høyrde på ulike songar METROLIST Det er på tide å sjå på kva du har høyrt på i år! Set i gang! Metrolist-merket 2026 DIN WRAPPED ER KLAR! På tide å sjå på kva du elska i år. Takk for at du høyrde Hjarteleg takk til Mo Agamy for å skapa Metrolist Steng wrapped Din %s Wrapped Oppretta speleliste Speleliste lagra %d profil %d profilar Ljodutjemnar Ingen ljodutjemnarprofilar Importer profil Systemljodutjemnar Slegen av %d band %d band Tak bort profil Er du viss på at du vil taka bort «%1$s»? Dette kan ikkje angrast. Klarte ikkje å lesa fil Klarte ikkje å opna fil: %1$s Importeringslyte Kastar til %s Framsteg %s %% Høyrer på Metrolist Opna Mislukkast i å skapa bilete: %s Kopierte tittel Kopierte artist Feil under avspeling Mislukkast i å tolka mellomtenar-URL Albumkunst Ingen song vert spela av Trykk for å opna Metrolist Førre Spela av/Pausa Neste Lika Musikkavspelarwidget med avspelingskontrollar Snartilgang til songen du spela av nylegast ================================================ FILE: app/src/main/res/values-or/metrolist_strings.xml ================================================ ସ୍ଥାନୀୟ ଦୂରସ୍ଥ ଚାର୍ଟ ପଛକୁ ଆଲବମ୍ ମଲାଟ ଶ୍ରେଷ୍ଠ ସଙ୍ଗୀତ ଭିଡିଓ ସଦ୍ୟତମ ଚର୍ଚ୍ଚିତ ସପ୍ତାହ ମାସ ବର୍ଷ ଲଗାତାର ପସନ୍ଦ କରିଥିବା ଡାଉନଲୋଡ ହୋଇଛି ମୋର ପ୍ରିୟ ଉପଲୋଡ ହେଇଛି ଅପଲୋଡ଼ ହେଇଛି ଚିତ୍ର ପ୍ରସ୍ତୁତ ହେଉଛି ଅପେକ୍ଷା କରନ୍ତୁ ବନ୍ଦ କରନ୍ତୁ ଚାଲୁ ================================================ FILE: app/src/main/res/values-pa/strings.xml ================================================ ਘਰ ਗੀਤ ਕਲਾਕਾਰ ਐਲਬਮ ਪਲੇਲਿਸਟ %d ਚੁਣਿਆ ਗਿਆ %d ਚੁਣੇ ਗਏ ਅਤੀਤ ਅੰਕੜੇ ਮੂਡ ਅਤੇ ਸ਼ੈਲੀ ਖਾਤਾ ਉਂਗਲਾਂ ਤੇ ਚੁਣੇ ਗੀਤ ਉਂਗਲਾਂ ਤੇ ਚੁਣੇ ਗੀਤ ਪਾਉਣ ਲਈ ਗੀਤ ਸੁਣਨੇ ਆਰੰਭ ਕਰੋ ਨਵੀਆਂ ਜਾਰੀ ਐਲਬਮਾਂ ਅੱਜ ਕੱਲ੍ਹ ਇਸ ਹਫ਼ਤੇ ਪਿਛਲੇ ਹਫ਼ਤੇ ਵੱਧ ਸੁਣੇ ਗਏ ਗੀਤ ਵਧੇਰੇ ਸੁਣੇ ਗਏ ਕਲਾਕਾਰ ਵਧੇਰੇ ਸੁਣੀਆਂ ਐਲਬਮਾਂ ਖੋਜੋ ਯੂਟਿਊਬ ਮਿਊਜ਼ਿਕ ਖੋਜੋ… ਲਾਇਬ੍ਰੇਰੀ ਖੋਜੋ… ਲਾਇਬ੍ਰੇਰੀ ਪਸੰਦ ਕੀਤੇ ਡਾਊਨਲੋਡ ਕੀਤੇ ਸਾਰੇ ਗੀਤ ਵੀਡੀਓ ਐਲਬਮ ਕਲਾਕਾਰ ਪਲੇਲਿਸਟਾਂ ਕਮਿਊਨਿਟੀ ਪਲੇਲਿਸਟਾਂ ਪ੍ਰਦਰਸ਼ਿਤ ਪਲੇਲਿਸਟਾਂ ਬੁੱਕਮਾਰਕ ਕੀਤੇ ਕੋਈ ਨਤੀਜੇ ਨਹੀਂ ਲੱਭੇ ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਪਸੰਦ ਕੀਤੇ ਗੀਤ ਡਾਊਨਲੋਡ ਕੀਤੇ ਗੀਤ ਪਲੇਲਿਸਟ ਖਾਲੀ ਹੈ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ ਰੇਡੀਓ ਸ਼ਫਲ ਕਰੋ ਰੀਸੈੱਟ ਕਰੋ ਵੇਰਵੇ ਸੰਪਾਦਿਤ ਕਰੋ ਰੇਡੀਓ ਸ਼ੁਰੂ ਕਰੋ ਚਲਾਓ ਅਗਲਾ ਚਲਾਓ ਕਤਾਰ ਵਿੱਚ ਜੋੜ੍ਹੋ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਹਟਾਓ ਡਾਊਨਲੋਡ ਡਾਊਨਲੋਡ ਹੋ ਰਿਹਾ ਡਾਊਨਲੋਡ ਹਟਾਓ ਪਲੇਲਿਸਟ ਅਯਾਤ ਕਰੋ ਪਲੇਲਿਸਟ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ ਕਲਾਕਾਰ ਵੇਖੋ ਐਲਬਮ ਵੇਖੋ ਮੁੜ ਪ੍ਰਾਪਤ ਕਰੋ ਸਾਂਝਾ ਕਰੋ ਮਿਟਾਓ ਅਤੀਤ ਵਿੱਚੋਂ ਹਟਾਓ ਆਨਲਾਈਨ ਖੋਜ ਕਰੋ ਸਿੰਕਰੋਨਾਈਜ਼ ਕਰੋ ਐਡਵਾਂਸਡ ਮਿਤੀ ਨਾਮ ਕਲਾਕਾਰ ਸਾਲ ਗਿਣਤੀ ਲੰਬਾਈ ਚਲਾਉਣ ਦਾ ਸਮਾਂ ਅਨੁਕੂਲਿਤ ਕ੍ਰਮ ਮੀਡੀਆ ਆਈਡੀ ਮਾਈਮ ਪ੍ਰਕਾਰ ਕੋਡੈਕਸ ਬਿੱਟਰੇਟ ਸੈਂਪਲ ਰੇਟ ਤੀਬਰਤਾ ਆਵਾਜ਼ ਫਾਈਲ ਆਕਾਰ ਅਗਿਆਤ ਕਲਿੱਪਬੋਰਡ \'ਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ ਬੋਲਾਂ ਨੂੰ ਸੰਪਾਦਿਤ ਕਰੋ ਬੋਲ ਖੋਜੋ ਗੀਤ ਸੰਪਾਦਿਤ ਕਰੋ ਗੀਤ ਦਾ ਸਿਰਲੇਖ ਗੀਤ ਕਲਾਕਾਰ ਗੀਤ ਦਾ ਸਿਰਲੇਖ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। ਗੀਤ ਕਲਾਕਾਰ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। ਸਾਂਭੋ ਪਲੇਲਿਸਟ ਚੁਣੋ ਪਲੇਲਿਸਟ ਸੰਪਾਦਿਤ ਕਰੋ ਪਲੇਲਿਸਟ ਬਣਾਓ ਪਲੇਲਿਸਟ ਦਾ ਨਾਮ ਪਲੇਲਿਸਟ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। ਕਲਾਕਾਰ ਸੰਪਾਦਿਤ ਕਰੋ ਕਲਾਕਾਰ ਦਾ ਨਾਮ ਕਲਾਕਾਰ ਦਾ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। %d ਗੀਤ %d ਗੀਤ %d ਕਲਾਕਾਰ %d ਕਲਾਕਾਰ %d ਐਲਬਮ %d ਐਲਬਮ %d ਪਲੇਲਿਸਟ %d ਪਲੇਲਿਸਟਾਂ %d ਹਫ਼ਤਾ %d ਹਫ਼ਤੇ %d ਮਹੀਨਾ %d ਮਹੀਨੇ %d ਸਾਲ %d ਸਾਲ ਪਲੇਲਿਸਟ ਅਯਾਤ ਕੀਤੀ ਗਈ ਪਲੇਲਿਸਟ ਤੋਂ \"%s\" ਹਟਾਇਆ ਗਿਆ ਪਲੇਲਿਸਟ ਸਿੰਕ ਹੋਈ ਅਣਕੀਤਾ ਕਰੋ ਬੋਲ ਨਹੀਂ ਮਿਲੇ ਸਲੀਪ ਟਾਈਮਰ ਗੀਤ ਸਮਾਪਤ %d ਮਿੰਟ %d ਮਿੰਟ ਕੋਈ ਸਟ੍ਰੀਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ ਕੋਈ ਨੈੱਟਵਰਕ ਕਨੈਕਸ਼ਨ ਨਹੀਂ ਸਮਾਂ ਸਮਾਪਤ ਅਗਿਆਤ ਤਰੁੱਟੀ ਪਸੰਦ ਕਰੋ ਪਸੰਦ ਹਟਾਓ ਸ਼ਫ਼ਲ ਆਨ ਸ਼ਫ਼ਲ ਆਫ ਰਿਪੀਟ ਮੋਡ ਆਫ ਮੌਜੂਦਾ ਗੀਤ ਦੁਹਰਾਓ ਕਤਾਰ ਨੂੰ ਦੁਹਰਾਓ ਸਾਰੇ ਗੀਤ ਖੋਜੇ ਗਏ ਗੀਤ ਸੰਗੀਤ ਪਲੇਅਰ ਸੈਟਿੰਗਾਂ ਦਿੱਖ ਡਾਇਨੈਮਿਕ ਥੀਮ ਚਾਲੂ ਕਰੋ ਗੂੜ੍ਹਾ ਥੀਮ ਚਾਲੂ ਬੰਦ ਸਿਸਟਮ ਦੀ ਪਾਲਣਾ ਕਰੋ ਸ਼ਾਹ ਕਾਲ੍ਹਾ ਡੀਫ਼ਾਲਟ ਟੈਬ ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਆਪਣੀ ਪਸੰਦ ਦਾ ਢਾਲੋ ਬੋਲਾਂ ਦੀ ਸਥਿਤੀ ਖੱਬੇ ਵਿਚਕਾਰ ਸੱਜੇ ਸਮੱਗਰੀ ਲਾਗ-ਇਨ ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਭਾਸ਼ਾ ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਦੇਸ਼ ਸਿਸਟਮ ਡੀਫ਼ਾਲਟ ਪ੍ਰੌਕਸੀ ਚਾਲੂ ਕਰੋ ਪ੍ਰੌਕਸੀ ਕਿਸਮ ਪ੍ਰੌਕਸੀ URL ਪ੍ਰਭਾਵੀ ਹੋਣ ਲਈ ਮੁੜ-ਚਾਲੂ ਕਰੋ ਪਲੇਅਰ ਅਤੇ ਆਡੀਓ ਆਡੀਓ ਕੁਆਲਿਟੀ ਆਟੋ ਉੱਚ ਘੱਟ ਨਿਰੰਤਰ ਕਤਾਰ ਚੁੱਪ ਤੇ ਸਕਿੱਪ ਕਰੋ ਆਡੀਓ ਨਾਰਮਲਾਈਜ਼ੇਸ਼ਨ ਈਕੋਲਾਈਜ਼ਰ ਸਟੋਰੇਜ ਕੈਸ਼ੇ ਚਿੱਤਰ ਕੈਸ਼ੇ ਗੀਤ ਕੈਸ਼ੇ ਵੱਧੋ-ਵੱਧ ਕੈਸ਼ੇ ਆਕਾਰ ਅਸੀਮਤ ਸਭ ਡਾਊਨਲੋਡ ਸਾਫ਼ ਕਰੋ ਅਧਿਕਤਮ ਚਿੱਤਰ ਕੈਸ਼ੇ ਆਕਾਰ ਚਿੱਤਰ ਕੈਸ਼ੇ ਸਾਫ਼ ਕਰੋ ਅਧਿਕਤਮ ਗੀਤ ਕੈਸ਼ੇ ਆਕਾਰ ਗੀਤ ਕੈਸ਼ ਸਾਫ਼ ਕਰੋ %s ਵਰਤਿਆ ਗਿਆ ਗੋਪਨੀਯਤਾ ਸੁਣਨ ਦਾ ਅਤੀਤ ਰੋਕੋ ਸੁਣੇ ਗੀਤਾਂ ਦੀ ਹਿਸਟਰੀ ਮਿਟਾਓ Are you sure to clear all listen history? ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਰੋਕੋ ਖੋਜ ਇਤਿਹਾਸ ਸਾਫ਼ ਕਰੋ ਕੀ ਤੁਸੀਂ ਸਾਰੇ ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਸਾਫ਼ ਕਰਨ ਲਈ ਯਕੀਨੀ ਹੋ? KuGou ਬੋਲ ਪ੍ਰਦਾਤਾ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ ਬੈਕਅੱਪ ਅਤੇ ਰੀਸਟੋਰ ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਇੰਪੋਰਟ ਕੀਤੀ ਪਲੇਲਿਸਟ ਬੈਕਅੱਪ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਇਆ ਗਿਆ ਬੈਕਅੱਪ ਨਹੀਂ ਬਣਾਇਆ ਜਾ ਸਕਿਆ ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰਨਾ ਅਸਫਲ ਰਿਹਾ ਐਪ ਦੇ ਬਾਰੇ ਐਪ ਸੰਸਕਰਣ ਨਵਾਂ ਸੰਸਕਰਣ ਉਪਲੱਬਧ ਹੈ ਆਨੁਵਾਦ ਦੇ ਮਾਡਲ ਅਨੁਵਾਦ ਦੇ ਮਾਡਲ ਮਿਟਾਓ ਭੁੱਲੇ ਹੋਏ ਮਨਪਸੰਦ ਗਾਣੇ ਸੁਣਦੇ ਰਹੋ ਤੁਹਾਡੀ ਯੂਟਿਊਬ ਚਾਲ ਸੂਚੀਆਂ ਇਹਦੇ ਵਰਗੇ ਲਾਇਬ੍ਰੇਰੀ ਲਾਇਬ੍ਰੇਰੀ ਗਾਇਕ ਇੱਥੇ ਦਿਖਣਗੇ ਲਾਇਬ੍ਰੇਰੀ ਟੇਪਾਂ ਇੱਥੇ ਦਿਖਣ ਗੀਆਂ ਤੁਹਾਡੀਆਂ ਚਾਲ ਸੂਚੀਆਂ ਇੱਥੇ ਦਿਖਣ ਗਿਆਂ ਟੇਪ ਦ੍ਰਿਸ਼ ================================================ FILE: app/src/main/res/values-pl/metrolist_strings.xml ================================================ Polubione Pobrane Mój Top Zaznacz wszystko Polub wszystko Data zaktualizowania Już na playliście: Styl tła odtwarzacza Zgodnie z motywem Gradient Proxy Zmień domyślny chip biblioteki Ustaw Szybki wybór Na podstawie poprzedniego wysłuchanego utworu Kiedykolwiek Ostatnie 24 godziny Ubiegły tydzień Ubiegły miesiąc Ubiegły rok Długość mojej Top listy Zdalna Wstecz Tygodnie Miesiące Lata Ciągły W pamięci podręcznej Anuluj Okładka Synchronizuj playlistę Rozmycie Domyślny Ogólne %d%% Informacje Wyświetlenia Polubienia Popularne Tekst Lokalna Wykresy Opis Kolor tła Pokaż playlistę \"Pobrane\" Dostosuj kolory Jest to ZAAWANSOWANA metoda logowania. Alternatywnie do portalu internetowego można tutaj bezpośrednio wprowadzić lub zaktualizować token logowania. Może to na przykład przyspieszyć logowanie na wielu urządzeniach. Należy pamiętać, że wszelkie nieprawidłowe formaty tokenów, których aplikacja nie przeanalizuje, nie zostaną zaakceptowane Kolor tekstu Drugi kolor tekstu Automatycznie dodawaj więcej podobnych utworów po osiągnięciu końca kolejki Czy na pewno chcesz wyczyścić wszystkie pobrane pliki? Najlepsze teledyski Synchronizacja wyłączona Uwaga: Umożliwia to synchronizację z YouTube Music. Nie można tego zmienić później. Generowanie obrazu Proszę czekać Udostępnij tekst Udostępnij jako tekst Udostępnij jako obraz Maksymalny limit wyboru Udostępnij wybrane Usuń z pamięci podręcznej Skopiuj link Odlajkuj wszystko Podobna zawartość Link skopiowany do schowka %d raz %d razy %d razy %d razy Kolory przycisków odtwarzacza Włącz przesunięcie, aby zmienić utwór Przesuń utwór w prawo, aby odtworzyć następny lub w lewo, aby dodać go do kolejki Zmiana tekstu po kliknięciu Wąski / Smukły Smukły pasek nawigacji Automatyczna lista odtwarzania Pokaż playlistę \"polubione\" Pokaż playlistę \"top\" Logowanie za pomocą tokena Stuknij, aby wyświetlić token Naciśnij ponownie, aby skopiować lub edytować Język aplikacji Włącz podobną zawartość Automatyczne pobieranie polubionych utwórów Automatycznie pobieraj utwory, gdy je polubisz Czy na pewno chcesz wyczyścić wszystkie utwory z pamięci podręcznej? Pokaż listę odtwarzania z pamięci podręcznej Niezalogowany do YouTube Otwórz obsługiwane linki Nie można otworzyć ustawień aplikacji Informacje o wersji Czas trwania historii Dislajki %d sekunda %d sekund %d sekund %d sekund Autoprzesuwanie tekstu Importuj listę odtwarzania \"m3u\" Importuj playlistę CSV Notatka: Dodawanie lokalnej muzyki do synchronizowanej/zewnętrznej listy odtworzeń nie jest wspierane. Wszelkie inne kombinacje są poprawne Romanizuj teksty japońskie Romanizuj teksty koreańskie Automatyczna synchronizacja z kontem Więcej zawartości Czułość przesunięcia Czy na pewno chcesz wyczyścić wszystkie obrazy z pamięci podręcznej? Nowy wygląd odtwarzacza %1$d%% Wyłączyć Nowy wygląd małego odwarzacza Subskrybuj Zasubskrybowano Teraz odtwarzane Zamknij Ukryj miniaturę odtwarzacza Zastąpienie okładki albumu logo aplikacji w odtwarzaczu +%1$d sekund do przodu -%1$d sekund wstecz Progresywne wyszukiwanie Jeśli włączone, dodaje 5 dodatkowych sekund przy każdym pominięciu wyszukiwania Wyłącz ładowanie więcej po powtórzeniu wszystkich Nie ładuj automatycznie większej liczby utworów i podobnych treści, gdy włączony jest tryb powtarzania wszystkich utworów Interfejs Prywatność i bezpieczeństwo Odtwarzacz i zawartość Pamięć i dane System i informacje Uruchamianie radia Edytuj okładkę playlisty Uwaga: Twoje konto musi być powiązane z numerem telefonu i zweryfikowane w YouTube Music, aby zmienić okładkę playlisty. Po wybraniu obrazu, zaczekaj chwilę, aż nowa okładka pojawi się na twojej playliście. Wybierz z biblioteki Usuń niestandardowy obraz Skonfiguruj proxy Nazwa użytkownika proxy Hasło proxy Włącz uwierzytelnianie Cyrylica Romanizacja Romanizacja tekstu Romanizuj teksty rosyjskie Romanizuj teksty ukraińskie Romanizuj teksty białoruskie Romanizuj teksty kirgiskie Romanizuj teksty serbskie Romanizuj teksty bułgarskie EKSPERYMENTALNE: Wykrywanie języka linijka po linijce Język cyrylicy będzie wykrywany wiersz po wierszu, zamiast w całej piosence. Jesteś pewien? Funkcja eksperymentalna.\n\nDomyślnie język jest określany na podstawie całego utworu, ale po włączeniu tej opcji będzie on określany wiersz po wierszu. Umożliwi to działanie utworów wielojęzycznych, ALE język może nie zawsze być poprawny (na przykład, jeśli tekst po ukraińsku nie zawiera liter charakterystycznych dla tego języka, może zostać zromanizowany na rosyjski).\n\nJeśli nie masz problemów, zalecamy wyłączenie tej opcji. Romanizuj bieżący utwór Włącz odciążenie Użyj ścieżki audio do odtwarzania dźwięku. Wyłączenie tej opcji może zwiększyć zużycie energii, ale może być przydatne w przypadku problemów z odtwarzaniem dźwięku lub jego przetwarzaniem Przesłane Przesłane Pokaż playlistę \"Przesłane\" Używaj detali zamiast stanu Pokaż wyraźnie nazwy utworów zamiast artystów Aktualizacje Automatycznie sprawdzaj dostępność aktualizacji Włącz powiadomienia o aktualizacjach Aktualizacja dostępna Powiadomienia o nowych wersjach Romanizuj macedońskie teksty Integracje Nazwa użytkownika Hasło Integracja Last.fm Włącz przewijanie Wyślij Teraz Odtwarzane Konfiguracja scrobblowania Kolor podstawowy Kolor trzeciorzędny Przesuń utwór, aby usunąć go z listy odtwarzania Ponowna synchronizacja Romanizuj chińskie teksty Aktualizacje aplikacji Google Cast Włącz przesyłanie dźwięku do Chromecasta i innych urządzeń obsługujących Cast Wyślij polubienia/niepolubienia Polub/niepolub piosenki w Last.fm, gdy są polubione/niepolubione w Metrolist Logowanie… Ukryj utwory wideo Wyświetl informacje o utworze Zmień tytuł lub artystę Utwórz stację na podstawie tego elementu Dodaj na górę kolejki Dodaj na dół kolejki Zapisz w swojej bibliotece Udostępnij do odtwarzania offline Dodaj do jednej ze swoich list odtwarzania Pobierz najnowsze metadane z YouTube Music Udostępnij link do tego elementu Trwale usuń ten element Zmień tempo i wysokość dźwięku utworu Dostosuj korektor dźwięku Włącz ikonę dynamiczną Miniodtwarzacz Czekaj! Wybrano limit rozmiaru pamięci podręcznej mniejszy niż ten, którego aplikacja aktualnie używa (%1$s). Jeśli będziesz kontynuować, aplikacja może usunąć część pamięci podręcznej %2$s, aby dopasować ją do nowego limitu. Czy mimo to kontynuować? Kontynuować Pobierz utwory do odtwarzania w trybie offline Usuń wszystkie pobrane utwory z tej playlisty Pobieranie w trakcie Podziel się playlistą z innymi Trwale usuń playlistę Synchronizuj playlistę z YouTube Music Fala Włącz efekt podświetlenia tekstu Dodaj efekty podświetlenia i odbicia do aktywnego tekstu Włącz \"Better Lyrics\" Tekst piosenki zsynchronizowany na poziomie sylab, idealny do karaoke Najpierw odtwórz losowo playlistę/album Podczas odtwarzania losowego najpierw odtwarzaj wszystkie utwory z oryginalnej playlisty/albumu, a potem podobne utwory Wyświetl kartę Wrapped Scrobbluj utwory dłuższe niż Procent opóźnienia scrobblowania Opóźnienie scrobblowania w minutach Miniodtwarzacz w czystej czerni Styl animacji synchronizowanych tekstów Brak Zanik Poświata Przesunięcie Karaoke Apple Music Wielkość tekstu utworów Odstępy między wierszami Okładka albumu %s Słuchałeś/aś Unikalne albumy Twój ulubiony album to Twoja osobista lista odtwarzania jest gotowa Twoje 5 ulubionych albumów Słuchałeś/aś tego albumu przez %d minut %d minut(-y) Brak danych Twoi najlepsi artyści roku %d minut(-y) Twoje ulubione utwory tego roku Okładka albumu Twoim ulubionym artystą tego roku jest Zdjęcie najlepszego artysty Słuchałeś/aś ich przez %d minut Twój najczęściej odtwarzany utwór to Słuchałeś/aś przez %d minut Słuchałeś/aś Unikalni artyści Słuchałeś/aś Unikalnie piosenki METROLIST Czas sprawdzić, czego słuchałeś/aś Zaczynamy! Logo Metrolist 2025 ­TWOJE WRAPPED JEST GOTOWE! Czas sprawdzić, co pokochałeś/aś w tym roku. Dziękujemy za słuchanie Specjalne podziękowania dla MO Agamy za stworzenie Metrolist Zamknij Wrapped Twoje %s Wrapped Utwórz playlistę Zapisano playlistę %d Profil %d Profili %d Profili %d Profili Korektor dźwięku Brak profili korektora dźwięku Zaimportuj profil Wyłączony %d zespół %d zespoły %d zespoły %d zespoły Usuń profil Czy na pewno chcesz usunąć %1$s? Tej akcji nie można cofnąć. Nie można odczytać pliku Nie udało się otworzyć pliku: %1$s Błąd importu Wysyłanie do %s Postęp %s%% Słuchasz Metrolist Otwórz Nie udało się utworzyć obrazu: %s Skopiowano Tytuł Skopiowano Wykonawcę Błąd odtwarzania Nie udało się przetworzyć adresu URL proxy. O artyście Pokaż więcej Pokaż mniej Profil artysty Więcej o artyście Pokaż liczbę obserwujących Miesięczna liczba słuchaczy Przytnij okładkę Wymuś kwadratowe miniatury wideo Pokazuj teksty z SimpMusic Tekst automatycznie pozyskany z Musixmatch i YouTube Transcript Pomijaj ciszę w utworach Natychmiast pomijaj ciszę Polub Widżet odtwarzacza z przyciskami sterowania Korektor systemowy Pomijaj ciszę bez zmiany tempa odtwarzania Trwałe mieszanie utworów Zachowaj tryb mieszania po zmianie utworu lub playlisty Zapamiętaj mieszanie i powtarzanie Zapamiętaj tryb mieszania i powtarzania po ponownym uruchomieniu aplikacji Wstrzymaj po wyciszeniu dźwięku Nie wygaszaj ekranu w widoku odtwarzacza Synchronizacja tekstu Błąd Nie udało się zastosować profilu korektora: %1$s Nie udało się odtworzyć utworu Okładka albumu Nic nie jest odtwarzane Dotknij, aby otworzyć Metrolist Poprzedni Odtwórz/Wstrzymaj Następny Okrągły widżet ze sterowaniem odtwarzania muzyki Usuwanie… Usunięto %1$d piosenek Profile Kanały Automatyczna playlista Pobrane odcinki Brak pobranych odcinków Brak zasubskrybowanych kanałów %d kanał %d kanały %d kanałów %d kanałów Przywrócić kopię? Włącz Jednolity Gęstość wyświetlania Uruchom ponownie Wymagane ponowne uruchomienie Zmiana gęstości wyświetlania zacznie obowiązywać po ponownym uruchomieniu aplikacji. Uruchomić ją ponownie teraz? Włącz LyricsPlus Synchronizuje teksty piosenek z różnych zródeł Wybór dostawcy tekstów utworów Wybierz, których dostawców tekstów utworów chcesz używać Zapobiegaj duplikatom utworów w kolejce Gdy dodajesz utwór do kolejki, usuwa jego wcześniejszą pozycję, jeśli był już w niej obecny Przeciągnij, aby zmienić kolejność dostawców tekstów utworów. Wyższa pozycja -> wyższy priorytet. Synchronizowana baza danych tekstów utworów tworzona przez społeczność Pobiera teksty utworów z KuGou, popularnej chińskiej platformy muzycznej UWAGA: Teksty z YouTube Music będą wyświetlane automatycznie, gdy inne teksty nie są dostępne. Teksty z YTM zwykle nie są synchronizowane. Kolejność dostawców tekstów utworów Serwer URL Nazwa użytkownika Połączono Ponowne łączenie… Rozłączono Łączenie… Błąd połączenia Utwórz serwer Utwórz serwer i podziel się kodem ze znajomymi Dołącz Kod serwera Jesteś hostem serwera Jesteś gościem Prośby o dołączenie Zobacz logi Debug połączeń i wiadomości Logi połączenia Na razie brak logów Słuchaj muzyki razem ze znajomymi w czasie rzeczywistym. Stwórz pokój aby stać się hostem lub dołącz do istniejącego serwera za pomocą kodu. Uwaga: Możesz zostać rozłączony, jeśli utworzysz serwer, gdy nie gra żadna muzyka, a następnie przełączysz się na inną aplikację. \"Słuchaj razem\" nie zostało skonfigurowane. Proszę ustawić URL serwera w Ustawienia → Integracje → Słuchaj razem. %1$s poprosił %2$s Sugestia została wysłana do hosta! %1$s chce dołączyć do serwera Słuchaj razem Lista zmian Brak dostępnych dzienników zmian https://github.com/MetrolistGroup/Metrolist/releases Zobacz na GitHub Obecna wersja Wersja: %s Zaktualizuj ustawienia Sprawdź dostępność aktualizacji Sprawdzanie dostępności aktualizacji… Sprawdź dostępność aktualizacji Ukryj dziennik zmian Pokaż dziennik zmian Nie udało się sprawdzić dostępności aktualizacji: %s Ustaw jako domyślny Wyłącznik czasowy domyślnie ustawiony na %d min Uruchom automatyczny wyłącznik czasowy Powtarzaj Codziennie Od poniedziałku do piątku Dni tygodnia / Weekendy Weekendy (Sob-Niedz) Godzina rozpoczęcia Godzina zakończenia Poniedziałek Wtorek Środa Czwartek Piątek Sobota Niedziela Stopniowe wyciszanie w ostatniej minucie Wznów po podłączeniu Bluetooth Crossfade Płynne przejście między utworami Czas płynnego przejścia między utworami Wyłącz dla albumów bez przerw Nie stosuj płynnego przejścia jeśli album jest bez przerw Funkcja Beta Crossfade to nowa funkcja i może zawierać błędy. Jeśli napotkasz jakiekolwiek problemy, prosimy o ich zgłoszenie\n\nTa funkcja wyłączna odciążane audio z powodu ograniczeń technicznych. Romanizuj tekst Hindi Romanizuj teksty w pendżabskim Wyłączone ponieważ włączono Crossfade Ukryj YouTube Shorts Eksportuj playlistę Eksportuj jako CSV Eksportuj jako M3U Playlista wyeksportowana pomyślnie Nie udało się wyeksportować playlisty Udostępnij Zapisz w Dokumentach Najnowsza: %s Automatycznie włącza wyłącznik czasowy z domyślną wartością o niestandardowym czasie Ustaw niestandardową godzinę, o której wyłącznik czasowy powinien się automatycznie aktywować Niestandardowy Zatrzymaj na końcu bieżącego utworu po zakończeniu odliczania czasu Pokaż zromanizowane teksty jako główne Znajdujące się w Ustawieniach > Zawartość odtworzeń Nie udało się zapisać epizodu Nie udało się usunąć epizodu Nie udało się zasubskrybować do podcastu Nie udało się odsubskrybować od podcastu Pokaż Kanał Brak odtwarzanego utworu Dotknij, aby otworzyć Metrolist Odtwarzacz muzyki Obrotnica Rozpoznawanie Muzyki Identyfikuj utwory odtwarzane dookoła ciebie bezpośrednio z ekranu głównego Dotknij, aby zidentyfikować piosenkę Słuchanie… Identyfikowanie… Brak pasujących wyników. Spróbuj ponownie Rozpoznanie nie powiodło się Wystąpił błąd. Spróbuj ponownie Nieznana piosenka Nieznany artysta Zidentyfikuj piosenkę Rozpoznawanie muzyki Wyświetla powiadomienie podczas identyfikowania utworu z widżetu Nagrywanie dźwięku w celu identyfikacji utworu… Razem Słuchanie Razem Wybierz serwer Niestandardowy serwer Użyj niestandardowego serwera Wycisz Odcisz Automatycznie zatwierdzaj prośby o dołączenie Automatycznie zatwierdza prośby o dołączenie zamiast przeglądania ich manualnie Automatycznie zatwierdzaj sugestie piosenek Automatyczne zatwierdzanie i dodawanie do kolejki sugestii piosenek od gości Synchronizuj głośność hosta Goście podążają za poziomem głośności gospodarza Słuchaj Razem na górnym pasku Pokaż Słuchaj Razem na górnym pasku aplikacji zamiast na pasku nawigacyjnym Powiadomienia o wydarzeniach w Słuchaj Razem Pokój stworzony: %s Nie można edytować nazwy użytkownika będąc w pokoju Oczekiwanie na zatwierdzenie przez hosta Nieprawidłowy kod pokoju Prośba o dołączenie odrzucona Dołącz do istniejącego pokoju Kod pokoju Opuść pokój Dołącz Stwórz Dołączanie do pokoju %s… Tworzenie pokoju… Połącz Rozłącz Stwórz Dołącz Zatwierdź Odrzuć Wyczyść Skopiuj Skopiowano do schowka Nie ustawiono Hostowanie pokoju W pokoju Oczekujące prośby Oczekujące sugestie Zaproponuj hostowi Wyrzuć Host Ty Połączeni użytkownicy Wprowadź nazwę użytkownika Wprowadź kod pokoju Skonfiguruj serwer, nazwę użytkownika i inne Nazwa użytkownika jest wymagana. Resynchronizuj Kopiuj kod Usuń tę osobę z sesji Zablokuj na stałe Zablokuj prośby o dołączenie tej osoby i ukryj jej sugestie Przenieś Własność Uczyń tę osobę hostem pokoju Zarządzaj użytkownikiem Zablokowani użytkownicy %d użytkowników zablokowanych Brak zablokowanych użytkowników Odblokuj Użytkownik zablokowany przez hosta Tłumaczenie tekstów AI Tłumaczenie tekstu... Tekst przetłumaczony Dostawca Podstawowy URL Klucz API Model Tryb Tłumaczenia Język Docelowy Klucze API Tłumaczenie Przetłumacz znaczenie na język docelowy Transkrypcja Konwertuj wymowę do skryptu docelowego Klucz API Wymagany Klucz API jest wymagany Brak tekstu do przetłumaczenia Tekst jest pusty Język docelowy jest wymagany Nieoczekiwany wynik tłumaczenia Wystąpił nieznany błąd Tłumaczenie nie powiodło się Pozyskaj Klucze API Odwiedź https://openrouter.ai, aby zapoznać się z modelami darmowymi i płatnymi Odwiedź https://platform.openai.com/api-keys Odwiedź https://console.anthropic.com/settings/keys Odwiedź https://aistudio.google.com/apikey Odwiedź https://perplexity.ai/settings/api Odwiedź https://console.x.ai Odwiedź https://deepl.com/pro-api, aby uzyskać bezpłatne i płatne klucze Formalność Domyślna Bardziej Formalna Mniej Formalna Aplikacja uległa awarii Wystąpił nieoczekiwany błąd. Udostępnij raport o awarii, aby pomóc nam rozwiązać problem. Udostępnij dzienniki Udostępnij raport o awarii Raport o awarii Metrolist Zamknij Brak dostępnego dziennika awarii Dynamiczna Karmazynowa Różowa Fioletowa Głęboko Fioletowa Indygo Niebieska Błękitna Cyjanowa Turkusowa Zielona Jasno Zielona Limonkowa Żółta Bursztynowa Pomarańczowa Głęboko Pomarańczowa Brązowa Szara Niebiesko-szara Powrót Tryb czystej czerni Tryb jasny Tryb ciemny Tryb systemowy paleta %1$s Odtwórz wszystkie Włącz wysoką częstotliwość odświeżania Wymuś działanie wyświetlacza z najwyższą obsługiwaną częstotliwością odświeżania (np. 120 Hz) Rozpoznawanie Muzyki Dotknij, aby rozpoznać Słuchanie… Procesowanie… Nie znaleziono dopasowania Błąd rozpoznawania Spróbuj ponownie Historia Rozpoznawania Wyczyść historię rozpoznawania Czy na pewno chcesz wyczyścić całą historię rozpoznawania? Usuń z historii Posłuchaj ponownie Odtwórz w Metrolist Rozpoznawaj muzykę Mapuj kolumny CSV Pierwszy wiersz to nagłówek Kolumna Nazwy artysty Kolumna tytułu piosenki Kolumna adresu URL YouTube (opcjonalnie) Kontynuuj Importowanie CSV Importowanie Playlisty Ostatnio Przekonwertowane Kol %d Status Online Bezczynny Nie przeszkadzać Przyciski Przycisk 1 Przycisk 2 Logowanie pomyślne! Ta funkcja wykorzystuje bibliotekę KizzyRPC do połączenia z bramą Discorda i ustawienia statusu Rich Presence. Chociaż nie odnotowano żadnych przypadków zawieszenia konta z powodu podobnego użycia, ta metoda nie jest oficjalnie obsługiwana przez Discord i może zostać uznana za naruszenie Warunków korzystania z usługi. Twój token jest pobierany lokalnie i nigdy nie jest wysyłany na serwery zewnętrzne. Postępuj według własnego uznania. Rodzaj aktywności Gra Słucha Ogląda Konkuruje Zmienne: {song_name}, {artist_name}, {album_name} Podgląd Rich Presence Obecność Zaloguj się za pomocą Discorda, aby udostępnić to, czego słuchasz Gra w Metrolist Ogląda Metrolist Konkuruje w Metrolist Nazwa aktywności Niestandardowa nazwa aktywności (pozostaw puste, aby ustawić domyślną nazwę) Tryb zaawansowany Pokaż dodatkowe opcje dostosowywania dla Rich Presence Szybkie wybieranie Przypnij do szybkiego wybierania Odepnij od szybkiego wybierania Losowa kolejność ekranu głównego Losowa zmiana kolejności sekcji ekranu głównego z uwzględnieniem priorytetów Brzmi jak %1$s Ponieważ słuchasz %1$s Podobny do %1$s Na podstawie %1$s Dla fanów %1$s Ze społeczności Zachować dane biblioteki? Czy chcesz zachować swoje playlisty i dane z biblioteki? Pobrane utwory i tak zostaną zachowane. Zachowaj Wyczyść Główny Deweloper Współpracownik Współpracownicy GNU General Public License v3.0 Darmowe oprogramowanie o otwartym kodzie źródłowym. Możesz z niego korzystać, studiować je, udostępniać i ulepszać. Serwer Discorda Kanał na Telegramie Strona Instagram GitHub Pokaż Repozytorium %1$s • %2$s Podoba Ci się to co robię? Kup mi kawę Społeczność i informacje METROLIST Chcesz zagrać ich ulubioną piosenkę? Spoko Ten projekt wspiera Palestynę 🇵🇸 Podcasty Pokaż podcast Kanały z podcastami Najnowsze Epizody Twoje Seriale Nowe Epizody Epizody na później Zapisz na później Dodaj do playlisty „Odcinki na później” Usuń z zapisanych Zapisz podcast w bibliotece %d epizod %d epizody %d epizody %d epizody Epizody Spowoduje to przywrócenie danych aplikacji z kopii zapasowej. Po przywróceniu będziesz musiał się ponownie zalogować. Następujące konto zostanie wylogowane: Przywróć Szukanie poprzedniego konta… Nie znaleziono konta Prześlij piosenki Przesyłanie… %1$d z %2$d Przesyłanie zakończone Przesyłanie nie powiodło się Plik za duży (maksymalnie 300MB) Nieobsługiwany format. Użyj mp3, m4a, wma, flac lub ogg Usuń przesłany utwór Czy na pewno chcesz usunąć ten przesłany utwór? Tej czynności nie można cofnąć. Przesłana piosenka została usunięta Nie udało się usunąć przesłanego utworu Usuń przesłane utwory Czy na pewno chcesz usunąć %1$d przesłanych utworów? Tej czynności nie można cofnąć. ================================================ FILE: app/src/main/res/values-pl/strings.xml ================================================ Główna Utwory Artyści Albumy Playlisty %d zaznaczony %d zaznaczone %d zaznaczonych %d zaznaczonych Historia Statystyki Nastroje i gatunki Konto Szybki wybór Słuchaj utworów, aby wygenerować szybkie wybory Nowo wydane albumy Dzisiaj Wczoraj Ten tydzień Ostatni tydzień Najczęściej słuchane utwory Najczęściej słuchani artyści Najczęściej słuchane albumy Szukaj Szukaj w YouTube Music… Szukaj w bibliotece… Biblioteka Polubione Pobrane Wszystko Utwory Filmy Albumy Wykonawcy Playlisty Playlisty społeczności Polecane playlisty Zapisane Brak wyników Z Twojej biblioteki Polubione utwory Pobrane utwory Playlista jest pusta Ponów Radio Losuj Reset Szczegóły Edytuj Włącz radio Odtwórz Odtwórz jako następny Dodaj do kolejki Dodaj do biblioteki Usuń z biblioteki Pobierz Pobieranie Usuń z pobranych Importuj playlistę Dodaj do playlisty Pokaż artystę Pokaż album Odśwież Udostępnij Usuń Usuń z historii Szukaj online Synchronizuj Zaawansowane Data dodania Nazwa Wykonawca Rok Liczba utworów Długość Długość utworu Niestandardowa kolejność ID media Typ MIME Kodeki Przepływność Częstotliwość próbkowania Natężenie Głośność Rozmiar pliku Nieznane Skopiowano do schowka Edytuj tekst Szukaj tekstu Edytuj utwór Tytuł utworu Wykonawcy utworu Tytuł utworu nie może być pusty. Wykonawca utworu nie może być pusty. Zapisz Wybierz playlistę Edytuj playlistę Utwórz playlistę Nazwa playlisty Nazwa playlisty nie może być pusta. Edytuj artystę Nazwa artysty Nazwa artysty nie może być pusta. %d utwór %d utworów %d utworów %d utworów %d artysta %d artystów %d artystów %d artystów %d album %d albumów %d albumów %d albumów %d playlista %d playlist %d playlist %d playlist %d tydzień %d tygodni %d tygodni %d tygodni %d miesiąc %d miesięcy %d miesięcy %d miesięcy %d rok %d lat %d lat %d lat Playlista zaimportowana Usunięto \"%s\" z playlisty Playlista zsynchronizowana Cofnij Nie znaleziono tekstu Wyłącznik czasowy Koniec utworu 1 minuta %d minut %d minut %d minut Brak dostępnego źródła Brak połączenia z Internetem Limit czasu Nieznany błąd Polub Usuń polubienie Losowanie włączone Losowanie wyłączone Powtarzanie wyłączone Powtórz bieżący utwór Powtórz kolejkę Wszystkie utwory Wyszukane utwory Odtwarzacz Ustawienia Wygląd Włącz motyw dynamiczny Ciemny motyw Włącz Wyłącz Zgodnie z systemem Czysta czerń Domyślnie otwarta zakładka Modyfikuj zakładki nawigacji Położenie tekstu utworu Z lewej Na środku Z prawej Zawartość Login Domyślny język zawartości Domyślny kraj zawartości Domyślny systemu Włącz proxy Typ proxy URL proxy Uruchom ponownie, aby zastosować zmiany Odtwarzacz i audio Jakość audio Automatyczna Wysoka Niska Trwała kolejka Pomiń ciszę Normalizacja audio Korektor Pamięć Pamięć podręczna Pamięć podręczna obrazów Pamięć podręczna utworów Maksymalny rozmiar pamięci podręcznej Nieskończony Wyczyść pobrane Maksymalny rozmiar pamięci podręcznej obrazów Wyczyść pamięć podręczną obrazów Maksymalny rozmiar pamięci podręcznej utworów Wyczyść pamięć podręczną utworów %s w użyciu Prywatność Wstrzymaj historię odtwarzania Wyczyść historię odtwarzania Czy na pewno chcesz wyczyścić całą historię odtwarzania? Wstrzymaj historię wyszukiwania Wyczyść historię wyszukiwania Czy na pewno chcesz wyczyścić całą historię wyszukiwania? Pobieraj teksty utworów z KuGou Kopia zapasowa i przywracanie Kopia zapasowa Przywróć Zaimportowane playlisty Kopia zapasowa utworzona pomyślnie Nie udało się stworzyć kopii zapasowej Nie udało się przywrócić kopii zapasowej O aplikacji Wersja aplikacji Dostępna nowa wersja Modele tłumaczeń Wyczyść modele tłumaczeń Zapewnij ciągłość odtwarzania Dodaj wszystko do biblioteki Duży Motyw Kolejka Przywróć ostatnią kolejkę podczas uruchomienia aplikacji Automatycznie załaduj więcej utworów Automatycznie dodaj więcej utworów, kiedy kolejka się skończy, o ile to możliwe Automatycznie pomiń do następnego utworu, gdy wystąpi błąd Zatrzymaj muzykę po wykonaniu zadania Gdy ta opcja jest włączona, zrzuty ekranu i widok aplikacji na ekranie Ostatnie są wyłączone. Pobieraj teksty utworów z LrcLib Mały Polub wszystkie Domyślny Usuń z playlisty Usuń wszystko z biblioteki Twoje playlisty YouTube Włącz Rich Presence Usuń z kolejki Błąd logowania Podobne do Twoje playlisty pojawią się tutaj Inne wersje Utwór jest już obecny w Twojej playliście Usuń wszystkie polubienia Wyrównanie tekstu odtwarzacza Z boku Falisty Zapomniane ulubione Słuchaj dalej Utwory z biblioteki pojawią się tutaj Artyści z biblioteki pojawią się tutaj Albumy z biblioteki pojawią się tutaj Czy na pewno chcesz usunąć wszystkie utwory playlisty \"%s\" z Pobranych utworów? Czy na pewno chcesz usunąć playlistę \"%s\"? Tempo i wysokość Duplikaty Pomiń duplikaty Dodaj mimo to %d utwory są już obecne w Twojej playliście Odtwarzacz Styl suwaka odtwarzacza Różne Rozmiar komórki siatki Nie zalogowano Słuchaj historii Szukaj w historii Wyłącz zrzut ekranu Ukryj wulgarne treści Integracja z Discordem Metrolist używa biblioteki KizzyRPC do aktualizacji statusu konta Discord. Wiąże się to z korzystaniem z połączenia Discord Gateway, co może być postrzegane jako naruszenie warunków korzystania z usług Discord (TOS). Nie są jednak znane przypadki zawieszenia kont użytkowników z tego powodu. Używaj na własne ryzyko.\n\nMetrolist wyodrębni tylko twój token; wszystko inne jest przechowywane lokalnie. Odrzuć Opcje Podgląd Wyloguj Zaloguj się, aby przeglądać treści Może mieć wpływ na to, jakie treści widzisz i np. pokazuje albumy tylko premium, jeśli jesteś zalogowany na konto Premium Zaloguj ================================================ FILE: app/src/main/res/values-pt/metrolist_strings.xml ================================================ Local Remoto Tabelas Voltar Capa do álbum Vídeos de música populares Em destaque Semanas Meses Anos Contínuo Favoritas Transferido O meu top Em cache Sincronizar lista de reprodução Sincronização desativada Observação: Isto permite a sincronização com o YouTube Music. Isto NÃO pode ser alterado mais tarde. A gerar imagem Por favor, aguarde Cancelar Partilhar letra Partilhar como texto Partilhar como imagem Limite máximo de seleção Partilhar selecionado Personalizar cores Cor do texto Cor secundária do texto Cor de fundo Remover do cache Copiar hiperligação Selecionar tudo Gostar de tudo Remover gostar de tudo Quando atualizado Hiperligação copiada para a área de transferência Letra Já está na lista de reprodução: %d vez %d vezes %d vezes Conteúdo similar Estilo do fundo do reprodutor Seguir o tema Gradiente Novo desenho do reprodutor Novo design do mini reprodutor Desfocado Cores dos botões do reprodutor Predefinição Ativar o deslizar para trocar de música Deslizar na música para esquerda para adicionar na fila ou para a direita para tocá-la em seguida Alterar letra ao clicar Rolar letra automaticamente Romanizar letras em japonês Romanizar letras em coreano Pequeno Barra estreita de navegação inferior Listas automáticas Mostrar lista de favoritos Mostrar lista de descarregados Mostrar lista de top Mostrar lista de músicas em cache Login com token Toque para mostrar o token Toque novamente para copiar ou editar Este é um método avançado de login. Como uma alternativa ao portal web, pode digitar ou atualizar o seu token diretamente por aqui. Como exemplo, isto pode fazer com que fazer login em vários dispositivos seja mais rápido. Observa que qualquer formato inválido do token que a app não consiga interpretar não será aceitado Sincronizar automaticamente com a conta Mais conteúdo Geral Proxy Alterar ecrã da biblioteca padrão Definir escolhas rápidas Com base na última música ouvida Idioma da app Ativar conteúdo similar Adicionar automaticamente mais músicas similares quando o final da fila for alcançado %d%% Importar uma lista \"m3u\" Importar uma lista \"csv\" Observação: Não há apoio para adicionar músicas locais em listas sincronizadas/remotas. Qualquer outra combinação é válida Descarregar automaticamente ao curtir Descarregar músicas automaticamente quando as curte Sensibilidade do gesto de deslizar no mini reprodutor %1$d%% Tem certeza que quer limpar todas as músicas em cache? Tem certeza que quer apagar todas as imagens no cache? Tem certeza que quer limpar todas as descargas? Desativar Não conectado ao YouTube Abrir ligações suportadas Não foi possível abrir as configurações da app Registo de mudanças Desde sempre Últimas 24 horas Última semana Último mês Último ano Comprimento da lista Meu Top Duração do histórico Informações Descrição Visualizações Favoritos Desgostados Inscrever-se Inscrito %d segundo %d de segundos %d segundos Fechar +%1$d segundos para a frente -%1$d segundos para trás Não carregar automaticamente mais músicas e conteúdo semelhante quando o modo repetir tudo está ativado Agora a Tocar Ocultar Miniatura do Reprodutor Substituir a capa do álbum pelo logótipo da aplicação no reprodutor Procura progressiva Se ativado, adiciona 5 segundos extra incrementalmente em cada ignorar na procura Desativar carregar mais quando repetir tudo Carregado Carregado Descarregar todas as músicas para ouvir offline Remover todas as músicas descarregadas desta lista de reprodução Descarregamento em progresso Partilhar esta lista de reprodução com os outros Remover esta lista de reprodução permanentemente Sincronizar lista de reprodução com o YouTube Music A começar rádio Cor primária Arrastar música para a remover da lista de reprodução Habilitar efeito de letras brilhantes Habilitar Better Lyrics Usar o provedor Better Lyrics para letras sincronizadas palavra-a-palavra Re-sincronizar Mostrar lista de reprodução \"Carregada\" Editar capa da lista de reprodução Nota: A tua conta tem de estar ligada a um número de telefone e verificada no YouTube Music para mudar a capa desta lista de reprodução. Depois de selecionar a imagem, por favor espera um momentinho para a nova capa aparecer na tua lista de reprodução. Escolher da biblioteca Remover imagem personalizada Configurar proxy Nome de utilizador proxy Palavra-passe proxy Habilitar autenticação Usar detalhes em vez de estado Mostrar titulo da música proeminentemente em vez de nome do artista Cirílica EXPERIMENTAL: Detetar língua linha por linha Tens a certeza? Interface Privacidade & Segurança Reprodutor & Conteúdo Armazenamento & Dados Sistema & Sobre Atualizador Procurar por atualizações automaticamente Habilitar notificações de atualizações Atualização Disponível Notificações sobre novas versões Google Cast Arte do álbum para %s Tu ouviste álbuns únicos O teu maior álbum é A tua lista de reprodução está pronta Os teus 5 maiores álbuns Tu ouviste este álbum durante %d minutos %d minutos Sem dados Os teus maiores artistas deste ano %d minutos As tuas maiores músicas do ano Capa do álbum O teu maior artista do ano é Imagem do maior artista Tu ouviste-os por %d minutos A tua música mais tocada é Tu ouviste-a por %d minutos Tu ouviste a artistas únicos Tu ouviste a músicas únicas METROLIST está na hora de ver o que andaste a ouvir vamos lá! Logo do Metrolist 2025 O TEU WRAPPED ESTÁ PRONTO! Está na hora de ver o que amaste este ano. Obrigado por ouvires Um agradecimento a MO Agamy por criar o Metrolist Fechar wrapped O teu %s Wrapped Criar lista de reprodução Cor terciária Misturar lista de reprodução/album primeiro Ondulado Adicionar animação brilhante e efeito saltitante à letra ativa Ao reproduzir aleatoriamente, reproduza primeiro todas as músicas da lista de reprodução/álbum original e, em seguida, conteúdo semelhante Mostrar cartão \"Wrapped\" Romanização Romanização de letras Romaniza letras em mandarim Romanizar letras em russo Romanizar letras em ucraniano Romanizar letras em bielorrusso Romanizar letras em quirguiz Romanizar letras em sérvio Romanizar letras em búlgaro O idioma cirílico será detetado linha por linha, em vez de toda a música. Esta é uma funcionalidade experimental que pode funcionar ou não. \n\nPor predefinição, o idioma é determinado a partir da música inteira, mas com esta opção ativada, ele será determinado linha por linha. Isso permitirá que músicas em vários idiomas funcionem, MAS o idioma pode nem sempre estar correto (por exemplo, se houver uma letra em ucraniano que não contenha letras específicas do ucraniano, ela pode ser romanizada como russo). \n\nSe não tiver problemas, recomenda-se manter esta opção desativada. Romanizar a faixa atual Atualizações da aplicação Habilitar descarregamento Use o caminho de descarregamento de áudio para reprodução de áudio. Desativar essa opção pode aumentar o consumo de energia, mas pode ser útil se você tiver problemas com a reprodução de áudio ou pós-processamento Ativar a transmissão de áudio para o Chromecast e outros dispositivos compatíveis com transmissão Romanizar letras em macedónio Integrações Utilizador Palavra-passe Integração com Last.fm Habilitar \"scrobbling\" Enviar a Tocar Agora Enviar Gostos/Não Gostos Músicas curtidas/descurtidas no Last.fm quando são curtidas/descurtidas no Metrolist A iniciar sessão… Configuração do \"Scrobbling\" \"Scrobblar\" músicas com duração superior a Percentagem de atraso do \"scrobble\" Atraso do \"scrobble\" em minutos Ocultar músicas em vídeo Ver informações da música Modificar o título ou o artista Criar uma estação baseada neste item Adicionar ao topo da sua fila Adicionar ao fim da sua fila Guardar na sua biblioteca Disponibilizar para reprodução \"offline\" Adicionar a uma das suas listas de reprodução Obter os metadados mais recentes do YouTube Music Partilhar um \"link\" para este item Remover este item permanentemente Alterar o tempo e o tom da música Ajustar o equalizador de áudio Habilitar o ícone dinâmico Mini-reprodutor Mini-reprodutor preto puro Aguarde! Escolheu um limite de tamanho de cache menor do que o que a aplicação está a utilizar atualmente (%1$s). Se continuar, a aplicação poderá remover alguns %2$s armazenados em cache para corresponder ao novo limite. Deseja continuar mesmo assim? Continuar Estilo da animação palavra-por-palavra Nenhum Desvanecer Brilho Deslizar Karaoke Apple Music Tamanho do texto das letras Espaçamento entre linhas das letras Listas de reprodução guardadas Equalizador Sem perfis de equalização Importar perfil Desativado Apagar perfil Tem a certeza de que deseja eliminar %1$s? Esta ação não pode ser desfeita. Impossível ler o ficheiro Falha ao abrir o ficheiro: %1$s Erro de importação Transmitir para %s Progresso %s%% Ouvir no Metrolist Aberto Falha ao criar imagem: %s Título copiado Artista copiado Erro de reprodução Falha ao processar o URL do proxy. %d perfil %d perfis %d perfis %d banda %d bandas %d bandas Pausar a música quando a multimédia está silenciada Sobre Mostrar mais Mostrar menos Página do artista Mostrar descrição do artista Mostrar contador de subscritores Mostrar ouvintes mensais Cortar Capa do Álbum Forçar uma proporção quadrada cortando as miniaturas de vídeos Habilitar SimpMusic Lyrics Usar o provedor SimpMusic Lyrics para letras sincronizadas Saltar instantâneamente silêncio Modo aleatório persistente Manter o modo aleatório ligado quando começarem novas músicas ou listas de reprodução Lembrar modo aleatório e repetir Lembrar modo aleatório e modo repetir quando a aplicação reiniciar Manter ecrã ligado quando o reprodutor estiver expandido Equalizador do Sistema Erro Erro na reprodução Arte do álbum Sem música a tocar Toca para abrir o Metrolist Retroceder Reproduzir/Pausar Próximo Gosto Widget de reprodutor de música com controlos de reprodução Acesso rápido para a tua faixa mais reproduzida recentemente Saltar as partes silenciosas das músicas em vez de aumentar a velocidade Avançar durante as partes silenciosas das músicas Habilitar Impedir faixas duplicadas em fila Ao adicionar uma faixa à fila, remova-a da sua posição anterior se já estiver presente Transição suave Transição suave entre músicas Duração da transição suave ================================================ FILE: app/src/main/res/values-pt/strings.xml ================================================ Início Álbuns Listas de reprodução %d selecionada %d selecionadas %d selecionadas Histórico Estatísticas Tom e Géneros Conta Escolhas rápidas Ouça músicas para gerar as escolhas rápidas Favoritas esquecidas Continuar a ouvir Listas de reprodução YouTube Semelhantes Hoje Ontem Álbuns mais reproduzidos Pesquisar Pesquisar no YouTube Music… Gosto Transferidas Tudo Pesquisar na biblioteca… Biblioteca Músicas Listas de reprodução da comunidade Listas de reprodução em destaque Marcadas Não há resultados As listas de reprodução aparecerão aqui As músicas da biblioteca aparecerão aqui Os artistas da biblioteca aparecerão aqui Outras versões Músicas transferidas A lista de reprodução está vazia Deseja remover do armazenamento das \"Músicas Transferidas\" todas as músicas da lista de reprodução \"%s\"? Deseja eliminar a lista de reprodução \"%s\"? Tentar novamente Rádio Misturar Detalhes Editar Iniciar rádio Reproduzir Seguinte Adicionar à fila Remover tudo da biblioteca Transferir A transferir Remover transferidas Importar lista de reprodução Adicionar à lista de reprodução Ver artista Ver álbum Partilhar Eliminar Remover do histórico Remover da lista de reprodução Pesquisar on-line Sincronizar Avançadas Tempo e Nota Tónica Data de adição Artista Ano Número de músicas Duração Tempo de reprodução Ordem personalizada Id. de multimédia Tipo MIME Codificadores Taxa de dados Frequência Volume Adicionar à biblioteca Adicionar tudo à biblioteca Remover da biblioteca Tamanho do ficheiro Desconhecido Copiado para a área de transferência Pesquisar letras Editar música Título O título não pode estar vazio. O artista não pode estar vazio. Guardar Escolher lista de reprodução Editar lista de reprodução Criar lista de reprodução Nome da lista de reprodução O nome da lista de reprodução não pode estar vazio. Editar artista Nome do artista O nome do artista não pode estar vazio. Ignorar duplicadas Duplicadas Esta música já está na lista de reprodução Adicionar na mesma %d músicas já estão na lista de reprodução %d música %d músicas %d músicas %d artista %d artistas %d artistas %d álbum %d álbuns %d álbuns %d lista de reprodução %d listas de reprodução %d listas de reprodução %d semana %d semanas %d semanas Todas as músicas Músicas pesquisadas Reprodutor de Música Definições Aparência Tema Ativar tema dinâmico Tema Escuro %d mês %d meses %d meses %d ano %d anos %d anos Lista de reprodução importada \"%s\" removida da lista de reprodução Lista de reprodução sincronizada Anular Letra não encontrada Temporizador Fim da música %d minuto %d minutos %d minutos Não existem fluxos Sem ligação de rede Caducidade Erro desconhecido Gostar Gostar de tudo Remover gosto Remover gosto de tudo Baralhar ativo Baralhar inativo Modo de repetição ativo Repetir música atual Repetir fila Inativo Tema do sistema Preto puro Reprodutor Alinhamento do texto do reprodutor Lado a lado Esquerda Centro Direita Estilo do cursor do reprodutor Difuso Separador inicial Tamanho da grelha Pequena Grande Conteúdo Iniciar sessão Sessão não iniciada Idioma predefinido do conteúdo Predefinição do sistema Ativar proxy Tipo de proxy URL do proxy Reinicie para aplicar Reprodução e áudio Pausar histórico de pesquisa Limpar histórico de pesquisa Deseja limpar todo o histórico de pesquisa? Desativar captura de ecrã Quando esta opção está ativada, as capturas de ecrã e a visualização da aplicação em \"Recentes\" estão desativadas. Ativar provedor de letras LrcLib Ativar provedor de letras KuGou Ocultar conteúdo explícito Cópia de segurança e restauro Baixa Fila Fila persistente Restaurar a sua última fila quando a aplicação inicia Carregar mais músicas automaticamente Ignorar silêncio Normalização de som Equalizador Cache Cache de imagens Cache de músicas Tamanho máximo Ilimitado Limpar todas as transferências Tamanho máximo para cache de imagens Limpar cache de imagens Tamanho máximo para cache de músicas %s utilizado Privacidade Histórico de reprodução Lista de reprodução importada Cópia de segurança criada com sucesso Não foi possível restaurar a cópia de segurança Integração Discord Ignorar Autenticação falhou Terminar sessão Ativar Rich Presence Sobre Versão da Aplicação Disponível nova versão Modelos de Tradução Limpar modelos de tradução Músicas Artistas Novos álbuns de lançamento Esta semana Semana passada Músicas mais reproduzidas Artistas mais reproduzidos Vídeos Álbuns Artistas Listas de reprodução Da sua biblioteca Músicas de que gosto Nome Remover da fila Repor Obter Sonoridade Os álbuns da biblioteca aparecerão aqui Editar letras Artistas da música Ativo Personalizar separadores Posição do texto da letra Predefinição Outras País predefinido do conteúdo Restaurar Efetuar Cópia Não foi possível criar a cópia de segurança Opções Prever Qualidade do áudio Alta Automática Pausar histórico de reprodução Limpar histórico de reprodução Armazenamento Limpar cache de músicas Se possível, adicionar mais músicas automaticamente quando é atingido o fim da fila Deseja limpar todo o histórico de reprodução? Histórico de pesquisas Garante um experiência contínua de reprodução Avançar automaticamente para a música seguinte se ocorrer algum erro Parar ao limpar a tarefa Metrolist utiliza a biblioteca KizzyRPC para definir o estado da sua conta Discord. Isto envolve uma ligação ao Discord Gateway, que pode ser considerada uma violação dos termos do serviço Discord. Contudo ,não há casos conhecidos de contas suspensas por este motivo. Utilize por sua conta e risco.\n\nMetrolist apenas irá extrair o seu token e tudo o resto é armazenado localmente. Inicie a sessão para ver o conteúdo de navegação Isto influencia o conteúdo visível, e por exemplo, mostra apenas álbuns Premium, se estiver autenticado com uma conta Premium Iniciar sessão ================================================ FILE: app/src/main/res/values-pt-rBR/metrolist_strings.xml ================================================ Semanas Meses Anos Contínuo Favoritas Baixadas Meu top Selecionar tudo Favoritar tudo Quando atualizado Letra Já está na playlist: %d vez %d de vezes %d vezes Conteúdo similar Estilo do fundo do tocador Seguir o tema Gradiente Desfocado Deslizar para trocar de música Alterar letra ao clicar Pequeno Barra de navegação inferior pequena Geral Proxy Alterar tela da biblioteca padrão Definir escolhas rápidas Com base na última música ouvida Idioma do app Habilitar conteúdo similar Adicionar automaticamente mais músicas similares quando o final da fila for alcançado Abrir links suportados Não foi possível abrir as configurações do app Registro de mudanças Desde sempre Últimas 24 horas Última semana Último mês Último ano Tamanho da playlist \"Mais Tocadas\" Duração do histórico %d segundo %d de segundos %d segundos Local Em cache Esse é um método avançado de login. Como uma alternativa ao portal web, você pode digitar ou atualizar o seu token diretamente por aqui. Como exemplo, isso pode fazer com que fazer login em vários dispositivos seja mais rápido. Observa que qualquer formato inválido do token que o app não consiga interpretar não será aceitado Informações Observação: Isso permite a sincronização com o YouTube Music. Isso NÃO pode ser alterado mais tarde. Deslizar na música para esquerda para adicionar na fila ou para a direita para tocá-la em seguida Mostrar playlist \"Mais Tocadas\" Mostrar playlist \"Em cache\" Remoto Ranques Voltar Arte do álbum Vídeos de música populares Em alta Sincronização desativada Remover do cache Copiar link Link copiado para a área de transferência Cores dos botões do tocador Padrão Playlists automáticas Mostrar playlist \"Baixadas\" Sincronizar playlist Desfavoritar tudo Mostrar playlist \"Curtidas\" Login com token Toque para mostrar o token Toque novamente para copiar ou editar %d%% Você tem certeza que quer limpar todas as músicas em cache? Você tem certeza que você quer limpar todos os downloads? Não conectado ao YouTube Descrição Visualizações Curtidos Não curti Cancelar Compartilhar letra Compartilhar como texto Compartilhar como imagem Limite máximo de seleção Compartilhar selecionado Customizar cores Cor do texto Cor secundária do texto Cor de fundo Baixar automaticamente ao curtir Baixar músicas automaticamente quando você as curte Por favor aguarde Gerando imagem Rolar letra automaticamente Importar uma playlist \"m3u\" Importar uma playlist csv Observação: Não há suporte para adicionar músicas locais em playlists sincronizadas/remotas. Qualquer outra combinação é válida Romanizar letras em Japonês Romanizar letras em Coreano Sincronizar automaticamente com a conta Mais conteúdo Novo design do tocador Sensibilidade do gesto de deslizar no mini tocador Tem certeza que quer apagar todas as imagens no cache? Desativar %1$d%% Inscrever-se Inscrito Novo design do mini tocador Tocando agora Fechar Ocultar miniatura do tocador Substituir arte do álbum com a logo do app no tocador +%1$d segundos para frente -%1$d segundos para trás Iniciando a rádio Ao ativar, adiciona acréscimos de até 5 segundos extras em cada pulo do avançar/retroceder Desativar o carregar mais ao repetir tudo Não carregar mais músicas automaticamente e conteúdo parecido quando o modo de repetir tudo está ativado Interface Privacidade e segurança Tocador e conteúdo Armazenamento e dados Sistema e informações Avançar/retroceder progressivo Editar capa da playlist Observação: A sua conta deve estar vinculada a um número de telefone e verificada no YouTube Music para alterar a capa da playlist. Ao selecionar uma imagem, aguarde um momento para que a capa nova apareça na sua playlist. Configurar proxy Nome do usuário da proxy Senha da proxy Ativar autenticação Alfabeto cirílico Romanização Romanização da letra Romanizar letras em Russo Romanizar letras em Ucraniano Romanizar letras em Bielorrusso Romanizar letras em Quirguiz Romanizar letras em Sérvio Romanizar letras em Búlgaro EXPERIMENTAL: Detectar idioma linha por linha O idioma Cirílico será detectado linha por linha em vez da música inteira. Tem certeza? Este é um recurso experimental que pode acertar ou errar.\n\nPor padrão, o idioma é determinado da música inteira, mas com esta opção ativada, será determinado de linha por linha. Isto permite que funcione para músicas em vários idiomas MAS o idioma pode estar incorreto (por exemplo se há uma letra em Ucraniano que não contém nenhum símbolo específico ao Ucraniano, ela pode ser romanizada como Russo).\n\nSe você não está tendo problemas, é recomendado manter esta opção desativada. Romanizar faixa atual Escolher da biblioteca Remover imagem customizada Ativar descarga Usar o caminho de descarga de áudio para a reprodução de áudio. Ao desativar isto, o consumo de energia aumenta, mas pode ser útil se ter problemas com reprodução de áudio ou pós-processamento Enviadas Enviado Mostrar playlist \"Enviadas\" Atualizador Conferir se há atualizações automaticamente Romanizar letras em Macedônio Ativar notificações de atualizações Atualização disponível Atualizações do aplicativo Notificações sobre novas versões Usar detalhes ao invés do estado Mostrar o título da música destacadamente em vez do nome do artista Integrações Nome do usuário Senha Integração com o Last.fm Ativar scrobbling Enviar reprodução atual Configuração de scrobbling Fazer scrobble de músicas mais longas que Porcentagem do atraso de Scrobble Minutos do atraso do Scrobble Deslizar música para removê-la da playlist Cor primária Cor terciária Sincronizar Romanizar letras em Chinês Google Cast Habilitar transmissão de áudio para o Chromecast e outros dispositivos compatíveis Enviar Likes/Deslikes Curtir/Descurtir músicas no Last.fm quanto são Curtidas/Descurtidas no Metrolist Fazendo login… Ocultar videoclipes Ver as informações da música Mudar o título ou artista Criar uma estação baseada neste item Adicionar ao topo da sua fila de reprodução Adicionar ao fim da sua fila de reprodução Salvar na sua biblioteca Tornar disponível para reprodução offline Adicionar à uma de suas playlists Obter os metadados mais recentes do YouTube Music Compartilhar o link deste item Remover permanentemente este item Mudar o andamento e o tom da música Ajustar o equalizador de áudio Ativar ícone dinâmico Mini-reprodutor Mini-tocador preto puro Espere! Você escolheu um limite do tamanho de cache menor do que o aplicativo está usando atualmente (%1$s). Se continuar, o aplicativo poderá remover parte do cache %2$s para corresponder ao novo limite. Continuar mesmo assim? Continuar Baixar todas as músicas para reprodução offline Remover todas as músicas baixadas desta playlist Transferência está em andamento Compartilhe esta playlist com outras pessoas Remover esta playlist permanentemente Sincronizar playlist com o YouTube Music Ativar o efeito de letras brilhantes Adicionar animação brilhante e efeito de salto para letras ativas Ativar Better Lyrics Letras sincronizadas sílaba por sílaba para qualquer música, perfeitas para karaokê Embaralhar playlist/álbum primeiro Quando no aleatório, tocar todas as músicas da playlist/álbum original primeiro, depois tocar conteúdo similar Mostrar cartão da retrospectiva Estilo de Animação das Letras Nenhum Desvanecer Brilhar Deslizar Karaoke Apple Music Tamanho do texto das letras Espaçamento de linha das letras Arte de álbum para %s Você já ouviu álbuns únicos Seu álbum preferido é Sua playlist pessoal está pronta Seus 5 álbuns mais ouvidos Você ouviu esse album por %d minutos Sem informações Seus artistas mais ouvidos do ano %d minutos Suas canções mais ouvidas do ano Arte do album Seu artista mais ouvido do ano é Melhor imagem do artista Você ouviu eles por %d minutos Sua canção mais tocada é Você ouviu por %d minutos Você ouviu Artistas únicos Você ouviu canções únicas METROLIST É hora de checar o que você tem ouvido vamos lá! Metrolist Logo 2025 SUA RETROSPECTIVA ESTÁ PRONTA! Hora de checar o que você amou esse ano. Obrigado por ouvir Agradecimentos especiais ao MO Agamy por criar o Metrolist Fechar retrospectiva Sua Retrospectiva %s Criar playlist Ondulado %d minutos Playlist salva %d Perfil %d Perfis %d Perfis Equalizador Sem perfis de equalizador Importar Perfil Desativado %d banda %d bandas %d bandas Apagar Perfil Tem certeza de que pretende apagar %1$s? Esta ação não pode ser desfeita. Não foi possível ler o arquivo Não foi possível abrir o arquivo: %1$s Erro ao importar Transmitir para %s Progresso %s%% Ouvindo Metrolist Abrir Falha ao criar a imagem: %s Título Copiado Artista Copiado Erro ao reproduzir Não foi possível analisar o url proxy. Pausar a música quando o dispositivo estiver sem som Habilitar letras do SimpMusic Usar o provedor SimpMusic Lyrics para letras sincronizadas Memorizar aleatório e repetir Memorizar aleatório e modo de repetição ao reiniciar o aplicativo Equalizador do Sistema Capa do Álbum Nenhuma música tocando Clique para abrir o Metrolist Anterior Tocar/Pausar Próximo Gostei Widget do tocador com controles de reprodução Widget circular de música com controles de reprodução e curtida Sobre Mostrar mais Mostrar menos Página do artista Mostrar descrição do artista Mostrar quantidade de inscritos Mostrar ouvintes mensais Avançar rapidamente a partes silenciosas das músicas Pular silêncio automaticamente Pular trechos silenciosos em vez de acelerar a reprodução Modo aleatório persistente Manter o modo aleatório ativado quando começar novas músicas ou playlists Atraso da letra Habilitar Cortar Capa do Álbum Forçar formato quadrado nas miniaturas dos vídeos Manter tela ligada enquanto o reprodutor estiver expandido Transição suave Transição suave entre as músicas Duração da transição suave Desativar para álbuns sem intervalos Não suavizar transição se o álbum for ininterrupto Recurso Beta Transição suave é uma nova função e podem haver erros. Se você experienciar qualquer problema, por favor reporte-os.\n\nEsse recurso desativa a descarga de áudio devido a limitações técnicas. Desabilitado porque a transição suave está ativa Erro inesperado Falha ao aplicar o perfil de equalização: %1$s Falha na reprodução Nenhuma música tocando Clique para abrir o Metrolist Reprodutor de música Toca-discos Juntos Ouvir juntos URL do servidor Escolher servidor Servidor personalizado Usar servidor personalizado Nome de usuário Conectado Reconectando… Desconectado Conectando… Erro de conexão Criar sala Crie uma sala e compartilhe o código com amigos Entrar na sala Código da Sala Você é o anfitrião Você é um participante Mutar Desmutar Pedidos para entrar Ver registros Registros de conexão Auto-aceitar pedidos de entrada Ativar alta taxa de atualização Forçar a exibição a rodar na maior taxa de atualização suportada (ex: 120Hz) Escutando… Processando… Nenhuma correspondência encontrada Erro no reconhecimento Tente novamente Limpar histórico de reconhecimento Tem certeza que quer limpar todo o histórico de reconhecimento? Apagar do histórico Ouvir novamente Tocar no Metrolist Aprovar pedidos de entrada automaticamente, ao invés de analisá-los manualmente Sincronizar volume do anfitrião Participantes seguem o nível de volume do anfitrião Ouvir juntos na barra superior Mostrar o Ouvir Juntos na barra superior do aplicativo ao invés da barra de navegação Ouça a músicas em tempo-real com seus amigos. Crie uma sala para ser o anfitrião ou junte-se a uma sala existente com um código. Nota: Você pode ser desconectado se criar uma sala e trocar para outro aplicativo enquanto nunhuma música estiver tocando. Ouvir Juntos não está configurado. Por favor configure o URL do servidor nas configurações → Integrações → Ouvir Juntos. Sala criada: %s Ocultar vídeos curtos do YouTube Depurar conexão e mensagens Sem registros ainda %1$s Solicitado %2$s Solicitação enviada ao dono! %1$s quer se juntar a sala Ouvir Juntos Notificações para eventos de Ouvir Juntos Não é possível editar o nome de usuário enquanto em uma sala Esperando aprovação do anfitrião Código de sala invalido Pedido de entrada recusado Entrar em uma sala existente Código da sala Sair da sala Entrar Criar Entrando na sala %s… Criando sala… Conectar Desconectar Criar Entrar Aprovar Rejeitar Limpar Copiar Copiado para a área de transferência Não definido Hospedar sala Na sala Solicitações pendentes Sugestões pendentes Sugerir ao anfitrião Expulsar Anfitrião Você Usuários Conectados Digite o nome de usuário Digite o código da sala Configure o servidor, o nome de usuário e muito mais É necessário nome de usuário. Ressincronizar Copiar código Remova esta pessoa da sessão Bloquear permanentemente Bloqueie os pedidos de entrada dessa pessoa e oculte as sugestões dela Usuários bloqueados %d usuário(s) bloqueado(s) Nenhum usuário bloqueado Desbloquear Usuário bloqueado pelo anfitrião Tradução de letras por IA Traduzindo letras de músicas... Letra traduzida Fornecedor URL base Faça dessa pessoa o anfitrião da sala Gerenciar usuário Chave de API Modelo Modo de tradução Língua de destino Credenciais da API Tradução Transcrição Chave de API necessária É necessária uma chave de API Não há letras para traduzir A letra está vazia É necessário o idioma de destino Resultado de tradução inesperado Ocorreu um erro desconhecido A tradução falhou O aplicativo travou Ocorreu um erro inesperado. Por favor, compartilhe o relatório de falha para nos ajudar a corrigir o problema. Compartilhar registros Compartilhar relatório de falha Relatório de Acidente da Metrolist Fechar Nenhum registro de falha disponível Dinâmico Carmesim Rosa Roxo Roxo Profundo Índigo Azul Azul celeste Ciano Verde Verde claro Verde-limão Amarelo Âmbar Laranja Laranja Escuro Marrom Cinza Azul acinzentado Voltar Modo Preto Puro Modo claro Modo escuro Modo de sistema paleta %1$s Reproduzir tudo Reconhecer música Toque para reconhecer Histórico de reconhecimento Mapear colunas CSV A primeira linha é o cabeçalho Coluna com o nome do artista Coluna com o título da música Coluna com URL do YouTube (Opcional) Continuar Importando CSV Convertido Recentemente Col %d Transferir a propriedade Verde-Azulado Impedir faixas duplicadas na fila Ao adicionar uma faixa à fila, remova-a da sua posição anterior se já estiver presente Traduzir significado para o idioma alvo Converter pronúncia para script de destino Obter chaves de API Visite https://openrouter.ai para modelos gratuitos e pagos Visite https://plataform.openai.com/api-keys Visite https://console.anthropic.com/settings/keys Visite https://aistudio.google.com/apikey Visite https://perplexity.ai/settings/api Visite https://console.x.ai Visite https://deepl.com/pro-api para chaves gratuitas e pagas Formalidade Padrão Mais formal Menos formal Status Online Ocioso Não perturbar Botões Botão 1 Botão 2 Login bem sucedido! Tipo de atividade Jogando Ouvindo Assistindo Competindo Variáveis: {song_name}, {artist_name}, {album_name} Esse recurso usa a biblioteca KizzyRPC para se conectar ao Gateway do Discord e definir seu status de Rich Presence. Embora nenhuma suspensão de conta conhecida tenha ocorrido a partir de uso semelhante, este método não é oficialmente suportado pela Discord e pode ser considerado uma violação de Termos de Serviço. Seu token é extraído localmente e nunca enviado para servidores de terceiros. Prossiga a seu próprio critério. Visualização de rich presence Presença Entre com o Discord para compartilhar o que você está ouvindo Jogando Metrolist Assistindo Metrolist Competindo no Metrolist Nome da atividade Nome personalizado para a atividade (deixe vazio para padrão) Modo avançado Mostrar opções de personalização adicionais para a Rich Presence Sólido Retomar ao conectar com Bluetooth Romanizar letras em Hindi Romanizar letras em Punjabi Mostrar letras romanizadas como principal Biblioteca de letras sincronizadas feitos pela comunidade Pega as letras do KuGou, uma plataforma de música Chinesa popular Observação: Letras do YouTube Music serão mostradas automaticamente quando não houver outras fontes disponíveis. As letras do YTM geralmente não são sincronizadas. Ativar LyricsPlus Letras sincronizadas de diversas fontes Fonte das letras Escolha quais fontes de letras deseja ativar Prioridade de provedor de letras Arraste para reordenar provedores por sua preferência. Mais ao topo > maior prioridade. Novidades Sem novidades no momento https://github.com/MetrolistGroup/Metrolist/releases Ver no GitHub Versão atual Versão: %s Configurações de atualização Verificar atualizações Verificando por atualizações… Última versão: %s Verificar atualizações Ocultar novidades Ver novidades Erro ao verificar atualizações: %s Definir como padrão Timer para soneca padrão: %d min Densidade de exibição Reiniciar Reinicialização necessária A mudança de densidade de exibição vai ser aplicada após reiniciar o app. Você deseja reiniciar agora? Encontrado em Configurações > Conteúdo reproduções Falha ao salvar episódio Falha ao remover episódio Falha ao se inscrever no podcast Falha ao se desinscrever do podcast Aprovar automaticamente sugestões de músicas Aprovar e enfileirar sugestões de convidados automaticamente Acesso rápido Fixar no acesso rápido Desafixar do acesso rápido Aleatorizar ordem da tela inicial Reordenar seções da tela inicial aleatoriamente com base na relevância Semelhante a %1$s Porque você ouve %1$s Parecido com %1$s Com base em %1$s Para os fãs de %1$s Da comunidade Manter dados da biblioteca? Deseja manter suas playlists e dados da biblioteca? As músicas baixadas serão mantidas de qualquer forma. Manter Limpar Desenvolvedor Líder Colaborador Colaboradores Licença Pública Geral GNU v3.0 Software grátis e de código aberto. Você pode usar, estudar, compartilhar e melhorá-lo. Servidor do Discord Canal do Telegram Site Instagram GitHub Ver repositório %1$s • %2$s Gosta do meu trabalho? Me compre um café Comunidade e Informações METROLIST Quer tocar a música favorita deles? Sim Esse projeto apoia a Palestina 🇵🇸 Podcasts Ver podcast Canais de podcast Últimos episódios Seus programas Novos episódios Episódios para depois Guarde para mais tarde Adicionar à sua playlist de Episódios para mais tarde Remover dos salvos Salvar podcast na biblioteca %d episódio %d episódios %d episódios Restaurar backup? Isso vai restaurar seus dados do backup. Você vai precisar fazer o login novamente após a restauração. A seguinte conta vai ser desconectada: Restaurar Verificando se há uma conta anterior… Nenhuma conta encontrada Importando playlist Reconhecedor de música Identifique músicas tocando ao seu redor diretamente da sua tela inicial Toque para identificar música Escutando… Identificando… Nenhuma correspondência encontrada. Tente novamente Reconhecimento falhou Um erro ocorreu. Por favor, tente novamente Música desconhecida Artista desconhecido Identificar música Reconhecimento de música Mostra uma notificação enquanto identifica uma música do widget Gravando áudio para identificar a música… Episódios Canais Playlist automática Episódios baixados Sem canais inscritos Sem episódios baixados %d canal %d canais %d canais Ativar timer para soneca automático Ativa o timer para soneca automaticamente com o valor padrão em um horário personalizado Defi­ne dia e horário personalizados em que o timer para soneca deve ativar automaticamente Repetir Diário Segunda à sexta Dias úteis / Finais de semana Finais de semana (Sáb-Dom) Personalizado Horário de início Horário de encerramento Segunda Terça Quarta Quinta Sexta Sábado Domingo Parar no final da música atual quando o timer acabar Desvanecer áudio no minuto final Ver canal Perfis Enviar músicas Enviando… %1$d de %2$d Envio completo Envio falhou Arquivo muito grande (máx. 300MB) Formato não suportado. Use mp3, m4a, wma, flac, ou ogg Apagar música enviada Tem certeza de que deseja apagar essa música enviada? Isto não pode ser desfeito. Música enviada apagada Falha para apagar a música enviada Apagar músicas enviadas Tem certeza de que deseja apagar %1$d músicas enviadas? Isto não pode ser desfeito. Apagou %1$d músicas Apagando… Exportar playlist Exportar como CSV Exportar como M3U Playlist exportada com sucesso Falha em exportar playlist Compartilhar Salvar nos Documentos Reconhecer música ================================================ FILE: app/src/main/res/values-pt-rBR/strings.xml ================================================ Início Músicas Artistas Álbuns Listas %d selecionado %d de selecionados %d selecionados Histórico Estatísticas Momentos e Gêneros Conta Escolhas rápidas Escute mais músicas para gerar suas escolhas rápidas Novos álbuns lançados Hoje Ontem Essa semana Semana passada Músicas mais tocadas Artistas mais tocados Álbuns mais tocados Pesquisar Pesquisar no YouTube Music… Pesquisar na biblioteca… Biblioteca Favoritos Baixados Tudo Músicas Vídeos Álbuns Artistas Listas Listas da comunidade Listas em destaque Salvos Nenhum resultado encontrado Da sua biblioteca Músicas favoritas Músicas baixadas Esta lista está vazia Tentar novamente Rádio Aleatório Redefinir Detalhes Editar Iniciar rádio Tocar Tocar em seguida Adicionar à fila Adicionar à biblioteca Remover da biblioteca Baixar Baixando Remover dos baixados Importar lista Adicionar à lista Ver artista Ver álbum Recarregar Compartilhar Apagar Remover do histórico Pesquisar online Sincronizar Avançado Quando adicionado Nome Artista Ano Número de músicas Duração Tempo de reprodução Ordem customizada ID da mídia Tipo do MIME Codificações Taxa de bits Taxa de amostragem Volume Volume Tamanho do arquivo Desconhecido Copiado para a área de transferência Editar letra Pesquisar letra Editar música Nome da música Artistas da música O nome da música não pode ser vazio. O artista da música não pode ser vazio. Salvar Selecione a lista Editar lista Criar lista Nome da lista O nome da lista não pode ser vazio. Editar artista Nome do artista O nome do artista não pode ser vazio. %d música %d de músicas %d músicas %d artista %d de artistas %d artistas %d álbum %d de álbuns %d álbuns %d lista %d de listas %d listas %d semana %d de semanas %d semanas %d mês %d de meses %d meses %d ano %d de anos %d anos A lista foi importada \"%s\" foi removido da lista A playlist foi sincronizada Desfazer A letra não foi encontrada Timer para dormir Fim da música %d minuto %d de minutos %d minutos Nenhum canal de reprodução disponível Sem conexão à internet Tempo esgotado Erro desconhecido Favoritar Remover dos favoritos Modo aleatório ativado Modo aleatório desativado Modo de repetição desativado Repetir a música atual Repetir fila Todas as músicas Músicas pesquisadas Tocador de música Configurações Aparência Ativar tema dinâmico Tema escuro Ativado Desativado Seguir o sistema Preto profundo Aba padrão ao abrir o app Customizar abas de navegação Posição do texto da letra Esquerda Centro Direita Conteúdo Conectar-se Idioma padrão do conteúdo País padrão do conteúdo Padrão do sistema Ativar proxy Tipo de proxy URL da proxy Reinicie para que tome efeito Tocador e áudio Qualidade do áudio Automática Alta Baixa Fila persistente Pular silêncio Normalização de áudio Equalizador Armazenamento Cache Cache de Imagens Cache de Músicas Tamanho máximo da cache Ilimitado Limpar todos os downloads Tamanho máximo da cache de imagens Limpar a cache de imagens Tamanho máximo da cache de músicas Limpar a cache de músicas %s usados Privacidade Pausar o histórico de reprodução Limpar o histórico de reprodução Você tem certeza que deseja limpar todo o histórico de reprodução? Pausar o histórico de pesquisa Limpar o histórico de pesquisa Você tem certeza que deseja limpar todo o histórico de pesquisa? Ativar o provedor de letras KuGou Backup e restauração Fazer backup Restaurar A lista foi importada O backup foi criado com sucesso Não foi possível criar o backup Falha ao restaurar o backup Sobre Versão do app Há uma nova versão disponível Modelos de Tradução Limpar os modelos de tradução Você tem certeza que deseja remover todas as músicas da lista \"%s\" do armazenamento de Músicas Baixadas? Você tem certeza que deseja apagar a lista \"%s\"? Remover da lista Pular duplicados Ativar o provedor de letras LrcLib Duplicados Adicionar mesmo assim Esta música já está na sua lista %d músicas já estão na sua lista Integração com o Discord Ignorar Opções Prévia Ocorreu algum problema ao conectar-se Desconectar-se Ativar o Rich Presence Não está conectado à uma conta O Metrolist usa a biblioteca KizzyRPC para definir o estado da sua conta do Discord. Isto envolve o uso da conexão do Discord Gateway, que pode ser considerado uma violação dos termos de serviço do Discord. Porém, não houve nenhum relato de usuários sendo banidos por esta razão. Use por sua própria conta e risco.\n\nO Metrolist extrairá somente o seu token, e todo o resto é armazenado localmente. Ao lado Ocultar conteúdo explícito Alinhamento do texto do tocador Estilo do controle deslizante do tocador Padrão Cobrinha Suas listas do YouTube Favoritar tudo Remover todos os favoritos Tamanho da célula da grade Adicionar mais músicas automaticamente quando o fim da fila é atingido, se possível Histórico de reprodução Desativar capturas de tela Parecido com Músicas na biblioteca aparecerão aqui Artistas na biblioteca aparecerão aqui Álbuns na biblioteca aparecerão aqui Outras versões Continue ouvindo Adicionar tudo à biblioteca Remover da fila Velocidade e Tonalidade Tocador Outros Pequeno Grande Fila Restaurar sua última fila ao iniciar o app Carregar mais músicas automaticamente Pular automaticamente para a próxima música quando um erro ocorre Garanta uma experiência de reprodução contínua Parar música ao remover dos apps recentes Histórico de pesquisa Quando esta opção está ativada, as capturas de tela e a visualização do app nos recentes são desativadas. Favoritos esquecidos Suas listas aparecerão aqui Tema Remover tudo da biblioteca Usar a conta para a navegação de conteúdo Isto pode influenciar em que conteúdo você vê, e se você tiver Premium por exemplo, verá álbuns que só estão disponíveis com Premium Conectar-se ================================================ FILE: app/src/main/res/values-ro/metrolist_strings.xml ================================================ Local La distanță Înapoi Copertă album Top videoclipuri muzicale Tendințe Săptămâni Luni Ani Continuu Apreciate Descărcate Topul meu În cache Sincronizează playlistul Sincronizare dezactivată Notă: Această setare permite sincronizarea cu YouTube Music. NU poate fi schimbată mai târziu. Se generează imaginea Așteaptă Anulează Distribuie versurile Distribuie ca text Distribuie ca imagine Limita maximă a selecției Distribuie selectatele Personalizează culorile Culoarea textului Culoarea secundară a textului Culoarea fundalului Elimină din cache Copiază linkul Selectează tot Apreciază toate Data actualizării Link copiat în clipboard Versuri Deja în playlist: %d dată %d ori %d de ori Conținut similar Stilul fundalului playerului Urmează tema Gradient Design nou al playerului Blur Culoarea butoanelor playerului Prestabilită Activează glisarea pentru schimbarea melodiei Glisează melodia la stânga pentru a o adăuga la coadă sau la dreapta pentru a o reda următoarea Schimbă versurile la apăsare Derulează automat versurile Romanizează versurile în japoneză Romanizează versurile în coreeană Subțire Bară de navigare compactă Playlisturi automate Arată playlistul \"Apreciate\" Arată playlistul \"Descărcate\" Arată playlistul \"Top\" Arată playlistul \"În cache\" Autentifică-te cu token Atinge pentru a afișa token-ul Atinge din nou pentru a-l copia sau edita Aceasta este o metodă AVANSATĂ de autentificare. Ca o metodă alternativă la portalul web, poți să-ți introduci sau să-ți actualizezi token-ul direct aici. De exemplu, acest lucru poate mări viteza de autentificare pe mai multe dispozitive. Reține că orice format nevalid de token pe care aplicația eșuează să-l analizeze nu va fi acceptat Sincronizează automat cu contul Mai mult conținut General Proxy Setează alegerile rapide Bazate pe ultima melodie ascultată Limba aplicației Activează conținut similar Adaugă automat mai multe melodii similare atunci când se ajunge la finalul cozii %d%% Importă un playlist \"m3u\" Importă un playlist \"csv\" Notă: Adăugarea de melodii locale la playlisturile sincronizate/de la distanță nu este suportată. Orice altă combinație este validă Descarcă automat la apreciere Descarcă melodiile automat atunci când le apreciezi Sensibilitate glisare mini player %1$d%% Sigur vrei să ștergi toate melodiile din cache? Sigur vrei să ștergi toate imaginile din cache? Sigur vrei să ștergi toate descărcările? Dezactivează Neautentificat la YouTube Deschide linkurile suportate Nu s-au putut deschide setările aplicației Notele lansării Lungimea listei Topul meu Durata istoricului Informații Descriere Vizualizări Aprecieri Abonează-te Abonat(ă) o secundă %d secunde %d de secunde Clasamente Din toate timpurile Ultimele 24 de ore Ultima săptămână Ultima lună Ultimul an Aprecieri negative Apreciază negativ toate Modifică secțiunea prestabilită a bibliotecii Design nou al mini playerului Acum se redă Închide Ascunde miniatura din player Înlocuiește coperta albumului cu logo-ul aplicației în player +%1$d (de) secunde înainte -%1$d (de) secunde înapoi Derulare progresivă Dacă este activată, adaugă 5 secunde suplimentare în mod incremental la fiecare salt al derulării Dezactivează \"Încarcă mai multe\" atunci când se repetă tot Nu încărca în mod automat mai multe melodii și conținut similar atunci când modul \"Repetă tot\" este activat Interfață Confidențialitate și securitate Player și conținut Stocare și date Sistem și despre Se pornește radioul Configurează proxy Nume de utilizator proxy Parolă proxy Activează autentificarea Romanizare Romanizarea versurilor Romanizează versurile în rusă Romanizează versurile în ucraineană Romanizează versurile în belarusă Romanizează versurile în kirghiză Romanizează versurile în sârbă Romanizează versurile în bulgară EXPERIMENTAL: Detectează limba linie cu linie Limbile chirilice vor fi detectate linie cu linie în loc de întreaga melodie. Ești sigur(ă)? Aceasta este o funcție experimentală cu posibilitate de succes sau eșec.\n\nÎn mod implicit, limba este determinată din întreaga melodie, dar cu această opțiune activată, va fi determinată linie cu linie. Acest lucru va permite ca melodiile multilimbă să funcționeze, DAR limba s-ar putea să nu fie mereu corectă (de exemplu dacă există un vers în ucraineană care nu conține vreo literă specifică limbii ucrainene, ar putea fi romanizat în rusă).\n\nDacă nu ai probleme, este recomandat să lași această opțiune dezactivată. Romanizează pista curentă Chirilic Editează coperta playlistului Notă: Contul tău trebuie să fie asociat cu un număr de telefon verificat pe YouTube Music pentru a putea schimba coperta playlistului. După selectarea unei imagini, așteaptă un moment pentru ca noua coperta să apară la playlistul tău. Alege din bibliotecă Elimină imaginea personalizată Activează offload Utilizează calea audio offload pentru redarea audio. Dezactivarea acestei setări poate crește utilizarea bateriei, dar poate fi utilă dacă întâmpini probleme cu redarea audio sau cu post procesarea Încărcate Încărcate Arată playlistul \"Încărcate\" Actualizator Verifică automat pentru actualizări Romanizează versurile în macedoniană Utilizează detalii în loc de stare Arată titlul melodiei în loc de numele artistului Activează notificările pentru actualizări Actualizare disponibilă Actualizări ale aplicației Notificări despre versiuni noi Integrări Nume de utilizator Parolă Integrare Last.fm Trimite Se redă acum Activează scrobblingul Configurare scrobbling Dă scrobble la melodiile mai lungi de Întârziere pentru scrobble în procente Întârziere pentru scrobble în minute Glisează melodia pentru a o elimina din playlist Trimite aprecierile Apreciază/dezapreciază melodiile în Last.fm atunci când sunt apreciate/dezapreciate în Metrolist Culoare principală Culoare terțiară Resincronizare Romanizează versurile în chineză Google Cast Activează difuzarea audio către Chromecast și alt dispozitive compatibile cu Cast Se conectează… Ascunde melodiile video Vezi informațiile melodiei Modifică titlul sau artistul Creează o stație bazată pe acest element Adaugă în partea de sus a cozii Adaugă în partea de jos a cozii Salvează în biblioteca ta Fă disponibil pentru redarea offline Adaugă într-unul dintre playlisturile tale Preia cele mai recente metadate de pe YouTube Music Distribuie un link către acest element Elimină acest element permanent Modifică tempo-ul și înălțimea melodiei Ajustează egalizatorul audio Activează pictograma dinamică Mini-player Mini-player complet negru Stai! Ai ales o limită de mărime a cache-ului mai mică decât cea pe care aplicația o folosește în prezent (%1$s). Dacă continui, aplicația ar putea elimina câțiva %2$s stocați pentru a corespunde cu noua limită. Continui oricum? Continuă Descarcă toate melodiile pentru redare offline Elimină toate melodiile descărcate din acest playlist Descărcarea este în progres Distribuie acest playlist cu alții Elimină acest playlist permanent Sincronizează playlistul cu YouTube Music Activează efectul de strălucire al versurilor Adaugă o animație de strălucire și un efect de bounce la versurile active Activează Better Lyrics Versuri sincronizate pe silabe pentru orice melodie, pentru karaoke Amestecă playlistul/albumul mai întâi Când amestecarea este pornită, redă toate melodiile din playlistul/albumul original mai întâi, apoi redă conținut similar Afișează cardul Wrapped Stil de animație cuvânt cu cuvânt Fără Estompare Strălucire Glisare Karaoke Apple Music Mărimea textului versurilor Spațierea între versuri Coperta albumului pentru %s Ai ascultat albume unice Albumul tău de top este Playlistul tău personal este gata Cele 5 albume de top ale tale Ai ascultat acest album timp de %d (de) minute %d minute Fără date Artiștii tăi de top din acest an %d (de) minute Melodiile tale de top din acest an Coperta albumului Artistul tău de top din acest an este Imaginea artistului de top L-ai ascultat/ai ascultat-o timp de %d (de) minute Cea mai redată melodie de-a ta este Ai ascultat-o timp de %d (de) minute Ai ascultat artiști unici Ai ascultat (de) melodii unice METROLIST este timpul să vedem ce ai ascultat să mergem! Logo-ul Metrolist 2025 WRAPPED-UL TĂU ESTE GATA! Este timpul să vedem ce ai apreciat în acest an. Mulțumim pentru ascultare Mulțumire speciale lui MO Agamy pentru crearea Metrolist Închide Wrapped Wrapped-ul tău %s Creează playlist Playlist salvat Se difuzează către %s Progres: %s%% Ascultă pe Metrolist Deschide Nu s-a putut crea imaginea: %s Titlul a fost copiat Artistul a fost copiat Eroare la redare Nu s-a putut analiza URL-ul proxy-ului. Ondulat Un profil %d profile %d de profile Egalizator Niciun profil pentru egalizator Importă profil Dezactivat O bandă %d benzi %d de benzi Șterge profilul Ești sigur(ă) că vrei să ștergi %1$s? Această acțiune nu poate fi anulată. Nu s-a putut citi fișierul Nu s-a putut deschide fișierul: %1$s Eroare la importare Întrerupe muzica atunci volumul media este setat la 0 Despre Afișează mai mult Afișează mai puțin Pagina artistului Afișează descrierea artistului Afișează numărul de abonați Afișează numărul de ascultători lunari Activează SimpMusic Lyrics Versuri preluate automat de pe Musixmatch și transcrierile YouTube Avansează rapid prin părțile silențioase ale melodiilor Omite liniștea instant Sari direct peste momentele silențioase în loc să mărești viteza de redare Memorează modurile de amestecare și de repetare Memorează modurile de amestecare și de repetare atunci când aplicația este repornită Decalaj versuri Egalizator de sistem Coperta albumului Nu se redă nicio melodie Atinge pentru a deschide Metrolist Anterioara Redă/Pauză Următoarea Apreciază Widget pentru playerul muzical cu butoane pentru redare Widget de muzică circular cu butoane de redare și apreciere Amestecare persistentă Menține modul de amestecare activat atunci când redai melodii sau playlisturi noi Decupează coperta albumului Forțează un raport de aspect pătrățos prin decuparea miniaturilor videoclipurilor Eroare Nu s-a putut aplica profilul pentru egalizator: %1$s Redarea a eșuat Menține ecranul pornit atunci când playerul este extins Ascultă împreună URL server Nume de utilizator Conectat Se reconectează… Neconectat Se conectează… Eroare la conexiune Creează cameră Creează o cameră și distribuie codul cu prietenii Alătură-te camerei Codul camerei Ești gazda Ești un invitat Cereri de alăturare Vezi jurnalele Depanare conexiune și mesaje Jurnale de conectare Niciun jurnal încă Ascultă muzică cu prietenii tăi în timp real. Creează o cameră pentru a fi gazda sau alătură-te unei camere existente cu un cod. Notă: S-ar putea să poți fi deconectat dacă creezi o cameră în timp ce nu se redă muzică și treci la o altă aplicație. Funcția Ascultă împreună nu este configurată. Te rugăm să setezi adresa URL a serverului în Setări → Integrări → Ascultă împreună. %1$s a cerut %2$s Sugestie trimisă gazdei! %1$s vrea să se alăture camerei Ascultă împreună Notificări pentru evenimentele funcției Ascultă împreună Cameră creată: %s Nu se poate edita numele de utilizator în timp ce ești într-o cameră Se așteaptă aprobarea de la gazdă Cod de cameră nevalid Cerere de alăturare respinsă Alătură-te unei camere existente Codul camerei Părăsește camera Alătură-te Creează Se alătură camerei %s… Se creează camera… Conectează-te Deconectează-te Creează Alătură-te Aprobă Respinge Elimină Copiază Copiat în clipboard Nesetat Găzduiește o cameră În cameră Cereri în așteptare Sugestii în așteptare Sugerează-i gazdei Dă afară Gazdă Tu Utilizatori conectați Introdu numele de utilizator Numele de utilizator este necesar. Resincronizează Aplicația s-a blocat A apărut o eroare neașteptată. Te rugăm să ne trimiți raportul de eroare pentru a ne ajuta să remediem problema. Partajează jurnalele Partajează raportul de eroare Raport de eroare Metrolist Închide Niciun jurnal de eroare disponibil Dinamic Mov Mov închis Indigo Albastru Cian Turcoaz Verde Verde deschis Lime Galben Portocaliu Portocaliu închis Maro Gri Înapoi Nu se redă nicio melodie Atinge pentru a deschide Metrolist Player de muzică Platan Alege serverul Server personalizat Folosește un server personalizat Mut Scoate de pe mut Aprobă automat cererile de alăturare Aprobă în mod automat cererile de alăturare în loc să le evaluezi manual Sincronizează volumul cu gazda Invitații folosesc același nivel al volumului ca și gazda Copiază codul Elimină această persoană din sesiune Blochează permanent Împreună Introdu codul camerei Configurează serverul, numele de utilizator și altele Blochează cererile de alăturare ale acestei persoane și ascunde-i sugestiile Transferă rolul de proprietar Fă această persoană gazda camerei Gestionează utilizatorul Utilizatori blocați %d (de) utilizatori blocați Niciun utilizator blocat Deblochează Utilizator blocat de gazdă Traducerea versurilor cu AI Se traduc versurile... Versuri traduse Furnizor URL de bază Cheie API Model Mod de traducere Limba țintă Acreditări API Traducere Transcriere Cheie API necesară Cheia pentru API este necesară Niciun vers de tradus Versurile sunt goale Limba țintă este necesară Rezultat neașteptat al traducerii A apărut o eroare necunoscută Traducere eșuată Trandafir Cer albastru Albastru-gri Mod negru pur Mod luminos Mod întunecat Modul systemului Paleta %1$s Redă tot Stacojiu Chihlimbar Recunoaște muzica Coloana cu URL-ul YouTube (opțional) Reascultă Ești sigur(ă) că vrei să elimini tot istoricul recunoașterilor? Nicio potrivire găsită Șterge din istoric Coloana cu numele artistului Se procesează… Elimină istoricul recunoașterilor Mapează coloanele CSV Col %d Eroare la recunoaștere Forțează afișajul să ruleze la cea mai ridicată rată de reîmprospătare suportată (de ex. 120Hz) Prima linie este antetul Încearcă din nou Atinge pentru a recunoaște Istoricul recunoașterilor Activează rata de reîmprospătare ridicată Coloana cu numele melodiei Convertite recent Se importă CSV-ul Redă pe Metrolist Se ascultă… Continuă Activează Caracteristică beta Tranziție lină Tranziție lină între melodii Durata tranziției line Dezactivează pentru albumele fără pauze Nu aplica efectul de tranziție lină dacă albumul nu are pauze Tranziția lină este o caracteristică nouă și este posibil ca aceasta să aibă bug-uri. Dacă întâmpini orice fel de problenă, te rugăm să ne-o raportezi.\n\nAceastă funcție dezactivează offload-ul audio datorită unor limitări tehnice. Dezactivat, deoarece funcția Tranziție lină este activată Previne piesele duplicate în coadă Când adaugi o piesă în coadă, elimin-o din poziția ei anterioară dacă există deja Ascunde YouTube Shorts Ascultă împreună în bara de sus Arată funcția Ascultă împreună în bara de sus în loc de în bara de navigare Tradu sensul în limba țintă Convertește punctuația în scriptul țintă Obține chei API Vizitează https://openrouter.ai pentru modele gratuite și plătite Vizitează https://platform.openai.com/api-keys Vizitează https://console.anthropic.com/settings/keys Vizitează https://aistudio.google.com/apikey Vizitează https://perplexity.ai/settings/api Vizitează https://console.x.ai Vizitează https://deepl.com/pro-api pentru chei gratuite și plătite Formalitate Prestabilită Mai formal Mai puțin formal Status Online Inactiv Nu deranja Butoane Butonul 1 Butonul 2 Autentificare reușită! Această funcție folosește biblioteca KizzyRPC pentru conectarea la gateway-ul Discord pentru a-ți seta statusul Rich Presence. În timp ce nu există suspendări cunoscute ale conturilor întâmpinate prin utilizări similare, această metodă nu este oficial acceptată de către Discord și poate fi considerată o încălcare a Termenilor Serviciului. Tokenul tău este extras local și niciodată trimis către servere terțe. Continui pe propriul risc. Tipul activității Se joacă Ascultă Vizionează Concurează Variabile: {song_name}, {artist_name}, {album_name} Previzualizare Rich Presence Prezență Autentifică-te cu Discord pentru a partaja ce asculți Se joacă Metrolist Vizionează Metrolist Concurează pe Metrolist Numele activității Nume personalizat pentru activitate (lasă gol pentru numele prestabilit) Mod avansat Afișează opțiuni adiționale de personalizare pentru Rich Presence Reia redarea la conectarea unui dispozitiv Bluetooth Romanizează versurile în hindi Romanizează versurile în punjabi Afișează versurile romanizate ca principale Solid Densitatea afișajului Repornire Repornire necesară Modificarea densității afișajului va avea efect după repornirea aplicației. Dorești să repornești acum? Bază de date pentru versuri sincronizate condusă de comunitate Preia versurile de pe KuGou, o platformă populară chinezească de muzică NOTĂ: Versurile de pe YouTube Music vor fi afișate automat atunci când alte versuri nu sunt disponibile. Versurile de pe YTM nu sunt de obicei sincronizate. Activează LyricsPlus Versuri sincronizate din mai multe surse Selecția de furnizori Alege ce furnizori de versuri sunt activați Prioritatea furnizorilor de versuri Trage pentru a reordona furnizorii după preferințe. Cu cât este mai înaltă poziția, cu atât prioritatea este mai înaltă. Jurnal de modificări Niciun jurnal de modificări disponibil https://github.com/MetrolistGroup/Metrolist/releases Vezi pe GitHub Versiunea curentă Versiune: %s Setări actualizări Verifică pentru actualizări Se verifică pentru actualizări… Cea mai recentă: %s Verifică pentru actualizări Ascunde jurnalul de modificări Vezi jurnalul modificărilor Nu s-a putut verifica pentru actualizări: %s Setează ca prestabilit Durata prestabilită a temporizatorul pentru somn a fost setată la %d (de) minute redă Nu s-a putut salva episodul Nu s-a putut elimina episodul Nu s-a putut abona la podcast Nu s-a putut dezabona de la podcast Recunoscător de muzică Identifică melodiile care se redau în jurul tău direct de pe ecranul de pornire Atinge pentru a identifica melodia Se ascultă… Se identifică… Nicio potrivire găsită. Încearcă din nou Recunoaștere eșuată A intervenit o eroare. Te rugăm să încerci din nou Melodie necunoscută Artist necunoscut Identifică melodia Recunoaștere muzicală Arată o notificare în timpul identificării unei melodii de pe widget Se înregistrează audio pentru identificarea melodiei… Aprobă automat sugestiile pentru melodii Aprobă și adaugă la coadă în mod automat sugestiile pentru melodii de la invitați Importă playlist Ordine aleatorie pe ecranul de pornire Ordonează aleatoriu secțiunile de pe ecranul de pornire cu priorități ponderate Pentru că asculți %1$s Similar cu %1$s Bazate pe %1$s Pentru fanii %1$s De la comunitate Păstrezi datele din bibliotecă? Dorești să-ți păstrezi playlisturile și datele din bibliotecă? Melodiile descărcate vor fi păstrate indiferent de situație. Păstrează Elimină Dezvoltator principal Colaborator Colaboratori Licența Publică Generală GNU v3.0 Software liber, cu sursă deschisă. Poți să-l folosești, studiezi, distribui și să-l îmbunătățești. Server de Discord Canal de Telegram Site web Instagram GitHub Vezi depozitul %1$s • %2$s Îți place ceea ce fac? Cumpără-mi o cafea Comunitate și informații METROLIST Vrei să redai melodia lui/ei preferată? Da Acest proiect ține cu Palestina 🇵🇸 Podcasturi Vezi podcastul Canale de podcasturi Cele mai recente episoade Emisiunile tale Episoade noi Episoade pentru mai târziu Salvează pentru mai târziu Adaugă la playlistul Episoade pentru mai târziu Eliminată din salvate Salvează podcastul în bibliotecă Un episod %d episoade %d de episoade Episoade Canale Playlist automat Episoade descărcate Nu te-ai abonat la niciun canal Niciun episod descărcat Un canal %d canale %d de canale Restaurezi backupul? Acest proces îți va restaura datele aplicației din backup. Trebuie să te autentifici din nou după restaurare. Următorul cont va fi deconectat: Restaurează Se verifică pentru conturi anterioare… Niciun cont găsit Poate fi găsită în Setări > Conținut Acces rapid Fixează la Acces rapid Anulează fixarea la Acces rapid Sună ca %1$s Activează temporizatorul de somn automat Activează temporizatorul de somn în mod automat cu valoarea prestabilită setată la o perioadă de timp personalizată Setează o zi și o oră personalizată la care temporizatorul de somn ar trebui să se activeze automat Repetă Zilnic De luni până vineri Zile lucrătoare / Weekenduri Weekenduri (sâmbătă-duminică) Personalizat Ora de început Ora de final Luni Marți Miercuri Joi Vineri Sâmbătă Duminică Stop la sfârșitul melodiei curente atunci când temporizatorul se oprește Estompare la minutul final Vezi canalul Profiluri Încarcă melodii Se încarcă… %1$d din %2$d Încărcare finalizată Încărcare eșuată Fișier prea mare (maxim 300 MB) Format neacceptat. Folosește mp3, m4a, wma, flac sau ogg Șterge melodia încărcată Ești sigur(ă) că vrei să ștergi această melodie încărcată? Această acțiune nu poate fi anulată. Melodia încărcată a fost ștearsă Nu s-a putut șterge melodia încărcată Șterge melodiile încărcate Ești sigur(ă) că vrei să ștergi %1$d (de) melodii încărcate? Această acțiune nu poate fi anulată. S-au șters %1$d (de) melodii Șe șterge… ================================================ FILE: app/src/main/res/values-ro/strings.xml ================================================ Acasă Melodii Artiști Albume Playlisturi Istoric Statistici Stare de spirit și genuri Cont Alegeri rapide Ascultă melodii pentru a-ți genera alegerile rapide Preferințe uitate Continuă să asculți Playlisturile tale de pe YouTube Similar cu Albume noi Astăzi Ieri Săptămâna aceasta Săptămâna trecută Cele mai redate melodii Cei mai redați artiști Cele mai redate albume Caută Caută pe YouTube Music… Caută în bibliotecă… Bibliotecă Apreciate Descărcate Toate Melodii Videoclipuri Albume Artiști Playlisturi Playlisturi comunitare Playlisturi promovate Marcate Niciun rezultat găsit Melodiile din bibliotecă vor apărea aici Artiștii din bibliotecă vor apărea aici Albumele din bibliotecă vor apărea aici Playlisturile tale vor apărea aici Din biblioteca ta Alte versiuni Melodii apreciate Melodii descărcate Playlistul este gol Sigur vrei să ștergi playlistul \"%s\"? Reîncearcă Radio Amestecă Resetează Detalii Editează Pornește radioul Redă Redă următoarea Adaugă la coadă Adaugă în bibliotecă Adaugă toate în bibliotecă Elimină din bibliotecă Elimină toate din bibliotecă Descarcă Se descarcă Elimină descărcarea Importă playlistul Adaugă în playlist Vezi artistul Vezi albumul Distribuie Șterge Elimină din istoric Elimină din playlist Elimină din coadă Caută online Sincronizează Avansat Tempo și tonalitate Data adăugării Nume Artist An Numărul melodiilor Lungime Timpul redării Ordine personalizată ID media MIME type Codecuri Rată de biți Rată de eșantionare Intensitate sonoră Volum Dimensiune fișier Necunoscut Copiat în clipboard Editează versurile Caută versuri Editează melodia Titlul melodiei Artiștii melodiei Numele melodiei nu poate fi gol. Artistul melodiei nu poate fi gol. Salvează Alege playlistul Editează playlistul Creează playlist Numele playlistului Numele playlistului nu poate fi gol. Editează artistul Numele artistului Numele artistului nu poate fi gol. Duplicate Omite duplicatele Adaugă oricum Melodia este deja în playlistul tău %d (de) melodii sunt deja în playlistul tău %d melodie %d melodii %d de melodii %d artist %d artiști %d de artiști %d album %d albume %d de albume %d playlist %d playlisturi %d de playlisturi %d săptămână %d săptămâni %d de săptămâni %d lună %d luni %d de luni %d an %d ani %d de ani Playlist importat S-a eliminat \"%s\" din playlist Playlist sincronizat Anulează Nu s-au găsit versuri Temporizator de somn Sfârșitul melodiei Un minut %d minute %d de minute Niciun stream disponibil Fără conexiune la rețea Timeout Eroare necunoscută Apreciază Apreciază tot Elimină aprecierea Elimină toate aprecierile Amestecare pornită Amestecare oprită Mod repetare oprit Repetă melodia curentă %d selectat %d selectate %d selectate Repetă coada Toate melodiile Melodii căutate Player de muzică Setări Aspect Temă Activează tema dinamică Temă întunecată Pornită Oprită Urmează sistemul Negru pur Personalizează filele de navigare Player Alinierea textului din player Poziția textului versurilor Stânga Centru Dreapta Stilul slider-ului player-ului Implicit Diverse Fila prestabilită deschisă Mărimea celulelor grilei Mică Mare Conținut Deconectează-te Autentifică-te Autentificare Nu ești autentificat Autentificare eșuată Limba prestabilită a conținutului Țara prestabilită a conținutului Prestabilită de sistem Activează proxy Tip proxy URL proxy Repornește pentru a lua efect Player și audio Calitate audio Automată Înaltă Scăzută Coadă Coadă persistentă Restaurează ultima ta coadă atunci când aplicația pornește Încarcă automat mai multe melodii Adaugă în mod automat mai multe melodii când ajungi la finalul cozii, dacă este posibil Omite liniștea Normalizare audio Sari automat la următoarea melodie atunci când apare o eroare Asigură-ți experiența continuă de redare Oprește muzica la terminarea sarcinii Egalizator Stocare Cache Cache imagini Cache melodii Dimensiunea maximă a cache-ului Nelimitată Elimină toate descărcările Dimensiunea maximă a cache-ului imaginilor Șterge cache-ul imaginilor Dimensiunea maximă pentru cache-ul melodiilor Șterge cache-ul melodiilor %s utilizat Confidențialitate Istoric ascultări Pune pe pauză istoricul ascultărilor Șterge istoricul ascultărilor Sigur vrei să ștergi tot istoricul ascultărilor? Istoric căutări Pune pe pauză istoricul căutărilor Șterge istoricul căutărilor Sigur vrei să ștergi tot istoricul căutărilor? Folosește autentificarea pentru a răsfoi conținut Această setare poate influența ce conținut vezi, de exemplu poate arăta albume doar pentru utilizatorii Premium dacă ești autentificat cu un cont Premium Dezactivează capturile de ecran Când această opțiune este pornită, capturile de ecran sunt dezactivate și aplicația nu poate fi văzută în Recente. Activează furnizorul de versuri LrcLib Activează furnizorul de versuri KuGou Ascunde conținutul explicit Backup și restaurare Fă backup Restaurează Playlist importat Backup creat cu succes Nu s-a putut creea backup-ul Nu s-a putut restaura backup-ul Integrare cu Discord Metrolist folosește biblioteca KizzyRPC pentru a-ți seta statusul contului tău de Discord. Acest lucru presupune folosirea conexiunii Discord Gateway, ceea ce ar putea fi considerată o încălcare a Termenilor serviciului Discord. Însă, nu există cazuri cunoscute de conturi de utilizator care au fost suspendate din acest motiv. Folosește pe propriul tău risc.\n\nMetrolist îți va extrage doar token-ul. Orice altceva este stocat local. Închide Opțiuni Previzualizează Activează Rich Presence Despre Versiunea aplicației Versiune nouă disponibilă Modele de traducere Elimină modelele de traducere Ondulat Reîncarcă Într-o parte Sigur vrei să ștergi toate cele %s (de) melodii din playlist din Melodii descărcate? ================================================ FILE: app/src/main/res/values-ru/metrolist_strings.xml ================================================ Назад Пожалуйста, подождите Удалить из кэша Выбрать всё Ссылка скопирована в буфер обмена Текст Уже в плейлисте: Похожий контент Стиль фона плеера Следовать теме Градиент Цветовой стиль кнопок плеера Свайп песни влево - добавить её в очередь, свайп песни вправо - воспроизвести её следующей Тонкий Нажмите, чтобы показать токен Нажмите еще раз, чтобы скопировать или изменить Прокси Основано на последнем прослушанном треке Язык приложения Включить похожий контент Автоматически добавлять больше похожих треков при достижении конца очереди Просмотры Включить переключение треков свайпом Скопировать ссылку %d секунда %d секунды %d секунд %d секунд Примечание: Это позволит синхронизировать плейлист с YouTube Music. Этот параметр НЕЛЬЗЯ изменить позже. Размытие Тонкая панель навигации Дизлайки Это ПРОДВИНУТЫЙ способ входа. Вместо использования веб-портала вы можете ввести или обновить токен для входа здесь. Это может быть полезно для быстрого входа на нескольких устройствах. Учтите, что приложение не примет токены с неверным форматом Лайки Чарты Обложка альбома Лучшие музыкальные клипы Недели Месяцы Годы Понравившиеся Скачанные Мой топ Кэшированные Синхронизировать плейлист Синхронизация выключена Отмена Поделиться текстом Тренды Генерация изображения Локальная Удалённая Цвет текста Цвет фона Непрерывно Поделиться как текст Поделиться как изображение Максимальный лимит выделения Поделиться выделенным Настроить цвета Цвет вторичного текста Нравится всё Не нравится всё Дата обновления %d раз %d раза %d раз %d раз Новый дизайн плеера По умолчанию Изменение текста песни по клику Автопрокрутка текстов песен Романизировать тексты на японском Романизировать тексты на корейском Автоплейлисты Показать плейлист \"Понравившиеся\" Показать плейлист \"Скачанные\" Показать плейлист \"Топ\" Показать плейлист \"Кэшированные\" Вход в систему с помощью токена Автосинхронизация с аккаунтом Доп. контент Общие Изменение чипа библиотеки (по умолчанию) Установить быстрый выбор %d%% Импортировать плейлисты \"m3u\" Импортировать плейлисты csv Примечание: добавление локальных композиций в синхронизированные/удаленные плейлисты не поддерживается. Любая другая комбинация допустима Автоскачивание по понравившимся Автоматически скачивать треки, отмечаемые как понравившиеся Вы уверены, что хотите очистить все закэшированные треки? Вы уверены, что хотите очистить все скачанные? Вы не вошли в YouTube Открыть поддерживаемые ссылки Невозможно открыть настройки приложения Примечания к выпуску Всё время Последние 24 часа Прошедшую неделю Последний месяц Прошлый год Длина моего топ-листа Длительность истории Информация Описание %1$d%% Отключить Подписаться Вы подписаны Вы уверены, что хотите очистить все закэшированные изображения? Чувствительность свайпа мини-плеера Новый дизайн мини‑плеера Сейчас играет Закрыть Скрыть миниатюру проигрывателя Заменить в проигрывателе обложку альбома на значок приложения +%1$d секунд вперёд -%1$d секунд назад Накопительная перемотка Если включено, при каждом пропуске перемотки добавляется ещё 5 секунд Отключить автозагрузку при повторе всех треков Отключить автоматическую подгрузку песен и похожего контента при включённом режиме повтора всех треков Интерфейс Конфиденциальность и безопасность Плеер и контент Хранилище и данные Система и сведения Запуск радио Настроить прокси Имя пользователя прокси Пароль прокси Включить аутентификацию Кириллица Романизация Романизация текста Романизировать тексты на русском Романизировать тексты на украинском Романизировать тексты на белорусском Романизировать тексты на киргизском Романизировать тексты на сербском Романизировать тексты на болгарском ЭКСПЕРИМЕНТАЛЬНО: Определять язык построчно Текст на кириллице будет определяться построчно, а не для всей песни. Вы уверены? Это экспериментальная функция, которая может работать не всегда.\n\nПо умолчанию язык определяется для всей песни, но с этой опцией он будет определяться построчно. Это позволит работать с многоязычными песнями, НО язык не всегда может быть правильным (например, если есть украинская строка, которая не содержит украинских букв, она может быть романизирована как русская).\n\nЕсли у вас нет проблем, рекомендуется оставить эту опцию выключенной. Романизировать текущий трек Редактировать обложку плейлиста Примечание: чтобы изменить обложку плейлиста, ваш аккаунт должен быть привязан к номеру телефона и подтверждён в YouTube Music. После выбора изображения подождите немного, пока новая обложка появится в вашем плейлисте. Удалить собственное изображение Выбрать из библиотеки Включить аппаратное ускорение аудио Использовать аппаратный путь воспроизведения аудио. Отключение этой функции может увеличить расход энергии, но может быть полезным, если у вас возникают проблемы с воспроизведением или постобработкой звука Загруженные Загруженные Показать плейлист \"Загруженные\" Романизировать тексты на македонском Обновления Автоматически проверять наличие обновлений Включить уведомления об обновлениях Доступно обновление Обновления приложения Уведомления о новых версиях Использовать подробности вместо статуса Показывать в заголовке название трека вместо исполнителя Интеграции Имя пользователя Пароль Интеграция с Last.fm Включить скробблинг Отправлять данные о текущем треке Настройки скробблинга Скробблить треки длиннее Задержка скробблинга (в процентах) Задержка скробблинга (в минутах) Свайп по песне, чтобы удалить её из плейлиста Отправлять лайки/дизлайки Добавлять/убирать отметку «любимая» на Last.fm, когда ставится/снимается отметка «Нравится» в Metrolist Романизировать тексты на китайском Google Cast Включить трансляцию звука на Chromecast и другие устройства с поддержкой Cast Скрыть видео Основной цвет Синхронизировать Просмотреть информацию о треке Изменить название или исполнителя Создать радиостанцию на основе этого элемента Добавить в начало очереди Добавить в конец очереди Сохранить в вашу библиотеку Сделать доступным офлайн Добавить в один из ваших плейлистов Обновить метаданные из YouTube Music Поделиться ссылкой на этот элемент Безвозвратно удалить этот элемент Изменить темп и высоту тона трека Настроить эквалайзер Включить динамический значок Мини-плеер Чисто чёрный мини-плеер Внимание! Вы выбрали лимит кэша меньше того, который приложение использует сейчас (%1$s). Если вы продолжите, часть данных %2$s может быть удалена, чтобы уложиться в новый лимит. Всё равно продолжить? Продолжить Третичный цвет Выполняется вход… Скачать все треки для офлайн воспроизведения Удалить все скачанные треки из этого плейлиста Идёт скачивание Поделиться этим плейлистом с другими Удалить этот плейлист навсегда Синхронизировать плейлист с YouTube Music Включить Better Lyrics Синхронизация текста по слогам для любой песни, для караоке Построчный стиль анимации Нет Затухание Свечение Скольжение Караоке Apple Music Размер текста песни Межстрочный интервал текста Перемешивать плейлист/альбом вначале Во время перемешивания сначала проигрывать все треки из исходного плейлиста/альбома, а затем похожий контент Включить эффект свечения текста Добавить анимацию свечения и эффект подпрыгивания для активных строк текста Показать карточку Wrapped Обложка альбома для %s Вы прослушали уникальных альбомов Ваш самый прослушиваемый альбом Ваш персональный плейлист готов Ваши топ-5 альбомов Вы слушали этот альбом %d минут %d минут Нет данных Ваши топ-исполнители года %d минут Ваши топ-треки года Обложка альбома Ваш главный исполнитель года Изображение главного исполнителя Вы слушали их %d минут Ваш самый прослушиваемый трек Вы слушали %d минут Вы слушали уникальных исполнителей Вы слушали уникальных треков METROLIST пришло время узнать, что вы слушали поехали! Логотип Metrolist 2025 ВАШ WRAPPED ГОТОВ! Время узнать, что вам понравилось в этом году. Спасибо за прослушивание Особая благодарность MO Agamy за создание Metrolist Закрыть Wrapped Ваш Wrapped %s Создать плейлист Плейлист сохранён Трансляция на %s Прогресс %s%% Вы слушаете Metrolist Открыть Не удалось создать изображение: %s Название скопировано Исполнитель скопирован Ошибка воспроизведения Не удалось разобрать url-адрес прокси. Волнистый %d профиль %d профиля %d профилей %d профилей Эквалайзер Нет профилей эквалайзера Импортировать профиль Отключено %d полоса %d полосы %d полос %d полос Удалить профиль Вы уверены, что хотите удалить «%1$s»? Это действие нельзя отменить. Не удалось прочитать файл Не удалось открыть файл: %1$s Ошибка импорта Останавливать воспроизведение при отключении звука Включить SimpMusic Lyrics Автоматически загружаемые тексты из Musixmatch и субтитров YouTube Сейчас ничего не играет Обложка альбома Нажмите, чтобы открыть Metrolist Предыдущий Играть/Пауза Следующий Нравится Виджет музыкального плеера с элементами управления воспроизведением Круглый музыкальный виджет с кнопками воспроизведения и лайка Системный эквалайзер Запоминать перемешивание и повтор Запоминать режим перемешивания и повтора при перезапуске приложения Смещение текста песни Ускорять воспроизведение тихих участков треков Мгновенно пропускать тишину Пропускать тихие фрагменты сразу вместо их ускорения О исполнителе Показать больше Показать меньше Страница исполнителя Показывать описание исполнителя Показывать количество подписчиков Показывать количество слушателей в месяц Постоянное перемешивание Сохранять режим перемешивания при воспроизведении новых треков или плейлистов Не удалось воспроизвести Ошибка Не удалось применить профиль эквалайзера: %1$s Обрезать обложку альбома Принудительно использовать квадратное соотношение сторон, обрезая миниатюры видео Не выключать экран при развернутом плеере Совместное прослушивание Адрес сервера Имя пользователя Подключено Переподключение… Отключено Подключение… Ошибка подключения Создать комнату Создайте комнату и поделитесь кодом с друзьями Присоединиться к комнате Код комнаты Вы — хост Вы — гость Запросы на вход Посмотреть логи Отладка соединения и сообщений Логи подключения Логов пока нет Слушайте музыку с друзьями в реальном времени. Создайте комнату или присоединитесь к существующей по коду. Примечание: возможно отключение, если вы создадите комнату без воспроизведения музыки и затем переключитесь в другое приложение. Совместное прослушивание не настроено. Укажите адрес сервера в разделе «Настройки → Интеграции → Совместное прослушивание». %1$s предложил(а): %2$s Предложение отправлено хосту! %1$s хочет присоединиться к комнате Совместное прослушивание Уведомления о событиях совместного прослушивания Комната создана: %s Нельзя изменить имя пользователя, находясь в комнате Ожидание одобрения от хоста Неверный код комнаты Запрос на вход отклонён Присоединиться к существующей комнате Код комнаты Покинуть комнату Войти Создать Подключение к комнате %s… Создание комнаты… Подключиться Отключиться Создать Войти Одобрить Отклонить Очистить Копировать Скопировано в буфер обмена Не указано Хостинг комнаты В комнате Ожидающие запросы Ожидающие предложения Предложить хосту Выгнать Хост Вы Подключённые пользователи Введите имя пользователя Имя пользователя обязательно. Синхронизировать заново Отключить звук Включить звук Сбой приложения Произошла непредвиденная ошибка. Пожалуйста, отправьте отчет о сбое, чтобы помочь нам устранить проблему. Поделиться логами Поделиться отчетом о сбое Отчет о сбое Metrolist Закрыть Лог сбоя недоступен Динамическая Багровый Розовый Фиолетовый Тёмно-фиолетовый Индиго Синий Небесно-синий Циановый Бирюзовый Зелёный Светло-зелёный Лаймовый Жёлтый Янтарный Оранжевый Тёмно-оранжевый Коричневый Серый Сине-серый Назад Режим чистого чёрного Светлый режим Тёмный режим Системный режим Палитра «%1$s» Выбрать сервер Пользовательский сервер Использовать пользовательский сервер Автоодобрение запросов на вход Запросы на вход будут одобряться автоматически, без ручной проверки Синхронизация громкости хоста У гостей будет та же громкость, что и у хоста Скопировать код Удалить этого пользователя из сессии Заблокировать навсегда Заблокировать запросы этого пользователя и скрыть его предложения Передать владение Сделать этого пользователя хостом комнаты Управление пользователем Заблокированные пользователи заблокировано пользователей: %d Нет заблокированных пользователей Разблокировать Пользователь заблокирован хостом Ничего не играет Нажмите, чтобы открыть Metrolist Музыкальный плеер Виниловый проигрыватель Вместе Введите код комнаты Настройка сервера, имени пользователя и прочего ИИ-перевод текста Перевод текста... Текст переведён Провайдер Базовый URL API-ключ Модель Режим перевода Язык перевода Учетные данные API Перевод Транскрипция Требуется API-ключ Необходим API-ключ Нет текста для перевода Текст песни пуст Не выбран язык перевода Неожиданный результат перевода Произошла неизвестная ошибка Ошибка перевода Играть все Распознать музыку Столбец ссылки на YouTube (необязательно) Прослушать снова Вы уверены, что хотите очистить всю историю распознавания? Совпадений не найдено Удалить из истории Столбец имени исполнителя Обработка… Очистить историю распознавания Сопоставление столбцов CSV Столбец %d Ошибка распознавания Принудительно использовать максимальную поддерживаемую частоту обновления экрана (например, 120 Гц) Первая строка — заголовок Попробовать снова Нажмите, чтобы распознать История распознавания Включить высокую частоту обновления Столбец названия песни Недавно конвертированные Импорт CSV Играть в Metrolist Слушаем… Продолжить Включить Кроссфейд Плавный переход между треками Длительность кроссфейда Отключать для альбомов без пауз Не применять кроссфейд, если альбом воспроизводится без пауз Бета-функция Кроссфейд - новая функция, которая может работать нестабильно. Пожалуйста, сообщите нам, если столкнётесь с ошибками.\n\nЭта функция отключает аппаратное ускорение аудио из-за технических ограничений. Отключено, так как включён кроссфейд Скрыть YouTube Shorts Совместное прослушивание в верхней панели Показывать «Совместное прослушивание» на верхней панели вместо панели навигации Показать дополнительные параметры настройки Rich Presence Играет Слушает Смотрит Соревнуется Переменные: {song_name}, {artist_name}, {album_name} Предпросмотр Rich Presence Присутствие Войдите через Discord, чтобы поделиться тем, что вы слушаете Играет в Metrolist Исключать дубликаты в очереди При добавлении трека в очередь удалять его из предыдущей позиции, если он уже присутствует Перевести смысл на язык перевода Преобразовать произношение в письменность языка перевода Получить API-ключи Перейдите на https://openrouter.ai для получения бесплатных и платных моделей Перейдите на https://platform.openai.com/api-keys Перейдите на https://console.anthropic.com/settings/keys Перейдите на https://aistudio.google.com/apikey Перейдите на https://perplexity.ai/settings/api Перейдите на https://console.x.ai Перейдите на https://deepl.com/pro-api для получения бесплатных и платных ключей Степень формальности По умолчанию Более формально Менее формально Статус В сети Неактивен Не беспокоить Кнопки Кнопка 1 Кнопка 2 Вход выполнен успешно! Эта функция использует библиотеку KizzyRPC для подключения к шлюзу Discord и установки статуса Rich Presence. Хотя случаев блокировки аккаунтов при подобном использовании не зафиксировано, данный метод официально не поддерживается Discord и может считаться нарушением Условий использования. Ваш токен извлекается локально и никогда не отправляется на сторонние серверы. Используйте на свой страх и риск. Тип активности Смотрит Metrolist Соревнуется в Metrolist Название активности Пользовательское название активности (оставьте пустым для значения по умолчанию) Расширенный режим Сплошной Возобновлять при подключении Bluetooth Романизировать тексты на хинди Романизировать тексты на панджаби Показывать романизированные тексты как основные Плотность интерфейса Перезапустить Требуется перезапуск Изменения плотности интерфейса вступят в силу после перезапуска приложения. Перезапустить сейчас? База синхронизированных текстов песен, поддерживаемая сообществом Берет тексты песен из KuGou, популярной китайской музыкальной платформы ПРИМЕЧАНИЕ: Тексты из YouTube Music будут показаны автоматически, если другие недоступны. Обычно тексты из YTM не синхронизированы. Включить LyricsPlus Синхронизированные тексты из нескольких источников Выбор поставщиков Выберите, какие поставщики текстов будут включены Приоритет поставщиков текстов Перетащите, чтобы изменить порядок поставщиков. Чем выше позиция -> тем выше приоритет. Список изменений Список изменений недоступен https://github.com/MetrolistGroup/Metrolist/releases Открыть на GitHub Текущая версия Версия: %s Настройки обновления Проверка обновлений Проверка обновлений… Последняя: %s Проверить обновления Скрыть список изменений Показать список изменений Не удалось проверить обновления: %s Установить по умолчанию Таймер сна по умолчанию установлен на %d мин Находится в Настройки > Контент прослушиваний Не удалось сохранить выпуск Не удалось удалить выпуск Не удалось подписаться на подкаст Не удалось отписаться от подкаста Автоодобрение предложений песен Предложения песен от гостей будут одобряться автоматически и добавляться в очередь Быстрый набор Закрепить в Быстром наборе Открепить от Быстрого набора Случайный порядок на главном экране Случайно менять порядок разделов на главном экране с учётом приоритетов Звучит как %1$s Потому что вы слушаете %1$s Похоже на %1$s На основе %1$s Для фанатов %1$s От сообщества Сохранить данные библиотеки? Хотите ли вы сохранить свои плейлисты и данные библиотеки? Скачанные треки будут сохранены в любом случае. Сохранить Очистить Ведущий разработчик Соавтор Соавторы GNU General Public License v3.0 Свободное программное обеспечение с открытым исходным кодом. Вы можете использовать, изучать, распространять и улучшать его. Сервер Discord Telegram-канал Веб-сайт Instagram GitHub Открыть репозиторий %1$s • %2$s Нравится то, что я делаю? Угостить меня кофе Сообщество и информация METROLIST Хотите включить их любимую песню? Да Этот проект поддерживает Палестину 🇵🇸 Подкасты Перейти к подкасту Каналы подкастов Последние выпуски Ваши шоу Новые выпуски Сохраненные выпуски Сохранить на потом Добавить в плейлист «Сохраненные выпуски» Удалить из сохранённых Сохранить подкаст в библиотеку %d выпуск %d выпуска %d выпусков %d выпусков Восстановить из резервной копии? Данные приложения будут восстановлены из резервной копии. Вам потребуется снова войти в аккаунт после восстановления. Будет выполнен выход из следующего аккаунта: Восстановить Проверка предыдущего аккаунта… Аккаунт не найден Импорт плейлиста Распознавание музыки Узнавайте, какая музыка играет рядом, прямо с главного экрана Нажмите для распознавания трека Слушаем… Определяем… Совпадений не найдено. Попробуйте снова Не удалось распознать Произошла ошибка. Пожалуйста, попробуйте снова Неизвестный трек Неизвестный исполнитель Определить трек Распознавание музыки Показывает уведомление при распознавании трека через виджет Запись звука для распознавания трека… Выпуски Каналы Автоплейлист Скачанные выпуски Нет подписанных каналов Нет скачанных выпусков %d канал %d канала %d каналов %d каналов Перейти к каналу Профили Включить автоматический таймер сна Автоматически включает таймер сна со значением по умолчанию в заданное время Установите день и время, когда таймер сна будет включаться автоматически Повтор Ежедневно По будням Будни / Выходные Выходные (Сб–Вс) Выборочно Время начала Время окончания Понедельник Вторник Среда Четверг Пятница Суббота Воскресенье Остановить по окончании текущего трека Плавное затухание в последнюю минуту Загрузить треки Загрузка… %1$d из %2$d Загрузка завершена Ошибка загрузки Файл слишком велик (макс. 300 МБ) Неподдерживаемый формат. Используйте mp3, m4a, wma, flac или ogg Удалить загруженный трек Вы уверены, что хотите удалить этот загруженный трек? Это действие нельзя отменить. Загруженный трек удален Не удалось удалить загруженный трек Удалить загруженные треки Вы уверены, что хотите удалить загруженные треки (%1$d)? Это действие нельзя отменить. Удалено треков: %1$d Удаление… Экспортировать плейлист Экспорт в CSV Экспорт в M3U Плейлист успешно экспортирован Не удалось экспортировать плейлист Поделиться Сохранить в «Документы» Распознать музыку ================================================ FILE: app/src/main/res/values-ru/strings.xml ================================================ Главная Композиции Исполнители Альбомы Плейлисты %d выбран %d выбрано %d выбрано %d выбрано История Статистика Настроение и жанры Аккаунт Быстрый выбор Послушайте несколько песен, чтобы создать ваш быстрый выбор Забытые избранные Продолжайте слушать Ваши плейлисты YouTube Похожие на Новые релизы альбомов Сегодня Вчера На этой неделе На прошлой неделе Самые прослушиваемые песни Самые прослушиваемые исполнители Самые прослушиваемые альбомы Поиск Поиск в YouTube Music… Поиск в библиотеке… Библиотека Понравившиеся Загруженные Все Композиции Видео Альбомы Исполнители Плейлисты Плейлисты сообщества Избранные плейлисты Добавлено в закладки Результаты не найдены Здесь будут отображаться песни из вашей библиотеки Здесь будут отображаться исполнители из вашей библиотеки Здесь будут отображаться альбомы из вашей библиотеки Здесь будут отображаться ваши плейлисты Из вашей библиотеки Другие версии Любимые треки Загруженная музыка Плейлист пуст Вы уверены, что хотите удалить все песни из плейлиста «%s» из хранилища загруженной музыки? Вы уверены, что хотите удалить плейлист «%s»? Повторить Радио Перемешать Сбросить Подробнее Редактировать Запустить радио Играть Играть следующим Добавить в очередь Добавить в библиотеку Добавить все в библиотеку Удалить из библиотеки Удалить все из библиотеки Загрузить Загрузка Удалить из загруженных Импортировать плейлист Добавить в плейлист Перейти к исполнителю Перейти к альбому Обновить Поделиться Удалить Удалить из истории Удалить из плейлиста Удалить из очереди Поиск в Интернете Синхронизировать Контроль аудио Темп и высота тона Недавно добавленные Название Исполнитель Год Количество треков Длительность Время воспроизведения Польз. порядок Идентификатор медиа Тип MIME Кодеки Битрейт Частота дискретизации Громкость Уровень громкости Размер файла Неизвестно Скопировано в буфер обмена Редактировать текст песни Поиск текста песни Редактировать композицию Название композиции Исполнители композиции Укажите название композиции. Укажите исполнителей композиции. Сохранить Выбрать плейлист Редактировать плейлист Создать плейлист Название плейлиста Укажите название плейлиста. Редактировать исполнителя Имя исполнителя Укажите имя исполнителя. Дубликаты Пропустить дубликаты Добавить в любом случае Эта песня уже в вашем плейлисте %d песен уже в вашем плейлисте %d композиция %d композиции %d композиций %d композиций %d исполнитель %d исполнителя %d исполнителей %d исполнителей %d альбом %d альбома %d альбомов %d альбомов %d плейлист %d плейлиста %d плейлистов %d плейлистов %d неделя %d недели %d недель %d недель %d месяц %d месяца %d месяцев %d месяцев %d год %d года %d лет %d лет Плейлист импортирован «%s» удалена из плейлиста Плейлист синхронизирован Отменить Текст песни не найден Таймер сна Конец песни %d минута %d минуты %d минут %d минут Нет доступных потоков Нет подключения к сети Тайм-аут Неизвестная ошибка Поставить «Нравится» Отметить все как «Нравится» Убрать «Нравится» Убрать все отметки «Нравится» Включить перемешивание Выключить перемешивание Выключить повторение Повторить текущую песню Повторить очередь Все композиции Искомые композиции Музыкальный плеер Настройки Внешний вид Тема Включить динамическую тему Темная тема Вкл. Выкл. Использовать настройки системы Режим чистого черного цвета Настройка вкладок навигации Плеер Выравнивание текста плеера Расположение текста песни Сбоку Слева По центру Справа Стиль ползунка плеера По умолчанию Волнистый Разное Вкладка навигации по умолчанию Размер ячейки сетки Маленький Большой Контент Логин Не авторизовано Язык контента Страна контента По умолчанию системы Включить прокси Тип прокси URL прокси Требуется перезапуск Плеер и аудио Качество аудио Авто Высокое Низкое Очередь Постоянная очередь Восстанавливать последнюю очередь при запуске приложения Автозагрузка большего количества песен Автоматически добавлять больше песен при достижении конца очереди, если это возможно Пропускать тишину в композициях Нормализация аудио Автопереход к следующей композиции при ошибке Обеспечить непрерывное воспроизведение Останавливать воспроизведение при очистке задач Эквалайзер Хранилище Кэш Кэш изображений Кэш аудио Максимальный размер кэша Неограниченно Очистить все загрузки Макс. размер кэша изображений Очистить кэш изображений Макс. размер кэша аудио Очистить кэш аудио %s использовано Конфиденциальность История прослушивания Приостановить историю прослушивания Очистить историю прослушивания Вы уверены, что хотите очистить всю историю прослушивания? История поиска Приостановить историю поиска Очистить историю поиска Вы уверены, что хотите очистить всю историю поиска? Отключить снимок экрана При включении этой опции скриншоты и отображение приложения в списке последних отключаются. Включить провайдера текстов LrcLib Включить провайдера текстов KuGou Скрывать контент с нецензурной лексикой Резервное копирование Создать резервную копию Восстановить из резервной копии Импортированный плейлист Резервная копия создана успешно Не удалось создать резервную копию Не удалось восстановить резервную копию Интеграция с Discord Metrolist использует библиотеку KizzyRPC для установки статуса вашего аккаунта Discord. Это включает использование соединения через Discord Gateway, что может считаться нарушением условий использования Discord. Однако, на данный момент нет известных случаев блокировки учетных записей пользователей по этой причине. Используйте на свой страх и риск.\n\nMetrolist будет извлекать только ваш токен, а все остальное хранится локально. Закрыть Настройки Предпросмотр Ошибка входа Выйти Включить Rich Presence О приложении Версия приложения Доступна новая версия Модели перевода Очистить модели перевода Авторизуйтесь для просмотра контента Это влияет на отображение контента и, в частности, открывает доступ к премиум-альбомам при входе с премиум-учётной записью Войти ================================================ FILE: app/src/main/res/values-sk/metrolist_strings.xml ================================================ Miestny Vzdialené Grafy Späť Obal albumu Najlepšie videá s hudbou Trendy Týždne Mesiace Roky Súvislé Páči sa mi Stiahnuté Moje najlepšie Vo vyrovnávacej pamäti Synchronizácia playlistu Synchronizácia zakázaná Poznámka: Toto umožňuje synchronizáciu s YouTube Music. Toto sa neskôr NEDÁ zmeniť. Generovanie obrázka Prosím, počkajte Zrušiť Zdieľať texty piesní Zdieľať ako text Zdieľať ako obrázok Maximálny limit výberu Zdieľať vybrané Prispôsobiť farby Farba textu Farba sekundárneho textu Farba pozadia Odstrániť z vyrovnávacej pamäte Kopírovať odkaz Vybrať všetko Všetko sa mi páči Nepáči sa mi všetko Dátum aktualizácie Odkaz skopírovaný do schránky Spustenie rádia Práve sa prehráva Text piesne Zatvoriť Skryť miniatúru prehrávača Nahradiť obal albumu logom aplikácie v prehrávači Už v playliste: %d raz %d krát %d krát %d krát +%1$d sekundy dopredu %1$d sekúnd dozadu Progresívne hľadanie Ak je povolené, pri každom preskočení vyhľadávania sa pripočíta 5 sekúnd navyše Podobný obsah Štýl pozadia prehrávača Podľa témy Prechod Nový dizajn prehrávača Nový dizajn mini prehrávača Rozostrenie Farby tlačidiel prehrávača Predvolené Povoliť zmenu skladby potiahnutím prstom Potiahnutím skladby doľava ju pridáte do frontu alebo doprava ju prehráte ako ďalšiu Zmena textu po kliknutí Automatické posúvanie textu piesní Romanizácia japonských textov Romanizácia kórejských textov Tenký Tenký spodný navigačný panel Automatické zoznamy skladieb Zobraziť zoznam skladieb s „Páči sa mi to“ Zobraziť zoznam skladieb „Stiahnuté“ Zobraziť playlist „Najlepší“ Zobraziť playlist „Uložený vo vyrovnávacej pamäti“ Prihlásenie pomocou tokenu Klepnutím zobrazíte token Klepnite znova pre kopírovanie alebo úpravu Toto je POKROČILÁ metóda prihlásenia. Ako alternatívu k webovému portálu môžete priamo zadať alebo aktualizovať svoj prihlasovací token tu. Môže to napríklad zrýchliť prihlásenie na viacerých zariadeniach. Prosím, nepoužívajte Automatická synchronizácia s účtom Viac obsahu Všeobecné Proxy Zmeniť predvolený čip knižnice Nastaviť rýchle výbery Na základe poslednej počúvanej skladby Jazyk aplikácie Povoliť podobný obsah Automaticky pridávať ďalšie podobné skladby po dosiahnutí konca frontu %d%% Importovať zoznamy skladieb vo formáte „m3u“ Importovať zoznamy skladieb vo formáte „csv“ Poznámka: Pridávanie lokálnych skladieb do synchronizovaných/vzdialených zoznamov skladieb nie je podporované. Akákoľvek iná kombinácia je platná Automatické sťahovanie pri lajkovaní Automaticky sťahujte skladby, keď sa vám páčia Citlivosť potiahnutia prstom v mini prehrávači %1$d%% Naozaj chcete vymazať všetky skladby uložené vo vyrovnávacej pamäti? Naozaj chcete vymazať všetky obrázky z vyrovnávacej pamäte? Naozaj chcete vymazať všetky stiahnuté súbory? Zakázať Nie ste prihlásený/á na YouTube Otvoriť podporované odkazy Nastavenia aplikácie sa nepodarilo otvoriť Poznámky k vydaniu Vždy Posledných 24 hodín Minulý týždeň Minulý mesiac Minulý rok Dĺžka môjho zoznamu najlepších Trvanie histórie Informácie Popis Zhliadnuťia Lajky Dislajky Odobrať Odoberané 1 sekunda %d sekundy %d sekúnd %d sekúnd Zakázať načítanie ďalších položiek pri opakovaní všetkých položiek Nenačítavať automaticky ďalšie skladby a podobný obsah, keď je zapnutý režim opakovania všetkých skladieb Rozhranie Súkromie a bezpečnosť Prehrávač a obsah Úložisko a dáta Systém a informácie Konfigurácia proxy servera Používateľské meno proxy servera Heslo proxy servera Povoliť overenie Cyrilika romanizácia Romanizácia textov Romanizovať ruské texty Romanizovať ukrajinské texty Romanizácia bieloruských textov Romanizovať kirgizské texty Romanizovať srbské texty Romanizovať bulharské texty EXPERIMENTÁLNE: Zistenie jazyka riadok po riadku Cyrilika bude detekovaná riadok po riadku, nie v celej skladbe. Si si istý/á? Toto je experimentálna funkcia, ktorá sa buď posúva dopredu, alebo dozadu.\n\nŠtandardne sa jazyk určuje z celej skladby, ale ak je táto možnosť zapnutá, bude sa určovať riadok po riadku. To umožní fungovanie viacjazyčných skladieb, ALE jazyk nemusí byť vždy správny (napríklad, ak existuje ukrajinský text, ktorý neobsahuje žiadne písmená špecifické pre ukrajinčinu, môže byť namiesto toho romanizovaný ako ruský).\n\nAk nemáte problémy, odporúča sa túto možnosť ponechať vypnutú. Romanizovať aktuálnu skladbu Zobraziť zoznam skladieb \"Nahrané\" Nahrané Nahrané Upraviť obal playlistu Poznámka: Na zmenu obalu playlistu musí byť váš účet prepojený s telefónnym číslom a overený v službe YouTube Music. Po výbere obrázka chvíľu počkajte, kým sa nový obal zobrazí vo vašom zozname skladieb. Vyberte si z knižnice Odstrániť vlastný obrázok Aktualizátor Automaticky kontrolovať aktualizácie Povoliť upozornenia na aktualizácie Aktualizácia je k dispozícii Aktualizácie aplikácií Upozornenia na nové verzie Povoliť odľahčenie Na prehrávanie zvuku použite cestu pre odľahčenie zvuku. Zakázanie tejto funkcie môže zvýšiť spotrebu energie, ale môže byť užitočné, ak máte problémy s prehrávaním zvuku alebo následným spracovaním Romanizovať macedónske texty Prejdením prstom odstráňte skladbu zo zoznamu skladieb Použite podrobnosti namiesto štátu Zobrazovať názov skladby výrazne namiesto mien interpretov Integrácie Používateľské meno Heslo Integrácia s Last.fm Povoliť scrobbling Odoslať Práve sa prehráva Konfigurácia scrobblingu Scrobble piesne dlhšie ako Percentuálne oneskorenie Scrobble Minúty oneskorenia Scrobble Odoslať označenia Páči sa mi to/Nepáči sa mi to Piesne, ktoré sa mi páčia/nepáčia, sa mi páčia na Last.fm, keď sa mi páčia/nepáčia na Metroliste Stiahnuť všetky skladby na počúvanie offline Odstrániť všetky stiahnuté skladby z tohto playlistu Prebieha sťahovanie Zdieľ\'ať tento playlist s ostatnými Natrvalo odstrániť tento playlist Synchronizácia playlistu s YouTube Music Primárna farba Terciárna farba Povoliť efekt žiariaceho textu lyriky Pridajte k aktívnej lyriky žiarivú animáciu a efekt odrážania Povoliť Better Lyrics Použiť poskytovateľa Better Lyrics pre texty synchronizované slovíčko po slovíčku Znovu synchronizovať Najprv prehrávať playlist/album náhodne Pri náhodnom prehrávaní najprv prehrávaj všetky skladby z pôvodného playlistu/alba, potom podobný obsah Zobraziť Wrapped kartu Prepis čínskych textov do latinky Povoliť prehrávanie zvuku na Chromecast a d\'alšie zariadenia s podporou Cast Prihlasovanie… Skryť skladby z videa Zobraziť informácie o skladbe Zmeniť názov alebo interpreta Vytvorte stanicu na základe tejto položky Pridať na začiatok frontu Pridať na koniec frontu Uložiť do knižnice Sprístupniť na prehrávanie offline Pridať do jedného zo svojich zoznamov skladieb Načítajte najnovšie metadáta z YouTube Music Zdieľať odkaz na túto položku Natrvalo odstrániť túto položku Zmeňte tempo a výšku tónu skladby Úprava zvukového ekvalizéra Povoliť dynamickú ikonu Mini-prehrávač Čisto čierny mini prehrávač Počkajte! Zvolili ste limit veľ\'kosti vyrovnávacej pamäte, ktorý je menší než ten ktorý aplikácia momentálne používa (%1$s). Ak budete pokračovať, aplikácia môže odstrániť niektoré uložené %2$s, aby sa prispôsobila novému limitu. Chcete napriek tomu pokračovať? Pokračovať Štýl animácie slovo po slove Žiadne Vyblednutie Žiara Posunúť Karaoke Apple Music Veľkosť textu lyriky Rozostupy lyriky Obal albumu pre %s Vypočuli ste si jedinečné albumy Váš najlepší album je Váš osobný playlist je pripravený Vašich 5 top albumov Tento album ste počúvali %d minút %d minút Žiadne údaje Vaši najobľúbenejší interpreti roka %d minút Vaše najobľ\'úbenejšie skladby roka Obal albumu Váš top interpret roka je Obrázok najobľúbenejšieho interpreta Počúvali ste ich %d minút Tvoja najhranejšia skladba je Počúvali ste %d minút Vypočuli ste si Jedineční interpreti Vypočuli ste si Jedinečné skladby METROLIST Je čas zistiť, čo ste počúvali poďme! Metrolist Logo 2025 VAŠE WRAPPED JE HOTOVÉ! Je čas pozrieť sa, čo sa vám tento rok páčilo. Vlnitý Google Cast Povoliť texty piesní SimpMusic Stránka interpreta Zobraziť popis interpreta Orezať obal albumu Zobraziť mesačných poslucháčov Ukázať viac Vynútenie štvorcového pomeru strán orezaním miniatúr videa Pre synchronizované texty piesní použite poskytovateľa textov SimpMusic Zobraziť počet odberateľov Ukázať menej O umelcovi Povoliť Solídny Zabráňte duplicitným skladbám vo fronte Pri pridávaní skladby do frontu ju odstrániť z jej predchádzajúcej pozície, ak už existuje Rýchly posun vpred cez tiché časti skladieb Okamžite preskočte ticho Preskočte vpred počas tichých chvíľ namiesto zrýchlenia prehrávania Trvalé náhodné prehrávanie Pri spustení nových skladieb alebo zoznamov skladieb nechajte zapnuté náhodné prehrávanie Zapamätajte si náhodné prehrávanie a opakovanie Zapamätajte si režim náhodného prehrávania a opakovania pri reštartovaní aplikácie Pozastaviť hudbu, keď sú médiá stlmené Pokračovať po pripojení Bluetooth Ponechať obrazovku zapnutú, keď je prehrávač rozbalený Prelínanie Prelínanie medzi skladbami Trvanie prelínania Zakázať pre albumy bez medzier Nepoužívajte prelínanie, ak je album bez medzier Funkcia beta Prelínanie je nová funkcia a môže obsahovať chyby. Ak narazíte na nejaké problémy, nahláste ich.\n\nTáto funkcia z dôvodu technických obmedzení zakazuje prenos zvuku. Romanizovať hindské texty Romanizovať pandžábske texty Text skladby Offset Zakázané, pretože je aktívne prelínanie Zobraziť romanizované lyriky ako hlavné Skryť YouTube Shorts Ďakujeme za vypočutie Špeciálne poďakovanie MO Agamy za vytvorenie Metrolistu Zatvoriť wrapped Vaše %s Wrapped Vytvoriť playlist Playlist uložený profil profily profilov profilov Ekvalizér Žiadne profily ekvalizéra Importovať profil Systémový ekvalizér Zakázané %d pásmo %d pásma %d pásiem %d pásiem Odstrániť profil ================================================ FILE: app/src/main/res/values-sk/strings.xml ================================================ Domov Piesne Umelci Albumy Playlisty %d vybrané %d vybrané %d vybrané %d vybrané História Štatistiky Nálada a žánre Účet Rýchle výbery Počúvajte skladby a vygenerujte si rýchle tipy Zabudnuté obľúbené Počúvať ďalej Vaše playlisty na YouTube Podobné ako Nové vydania albumov Dnes Včera Tento týždeň Minulý týždeň Najhranejšie skladby Najhranejší interpreti Najprehrávanejšie albumy Hľadať Vyhľadávať YouTube Music… Hľadať v knižnici… Knižnica Obľúbené Stiahnuté Všetko Skladby Videá Albumy Interpreti Playlisty Zdieľané playlisty Odporúčané playlisty Označené Nenašli sa žiadne výsledky Knižnica Tu uvidíš umelcov zo svojej knižnice Tu uvidíš albumy zo svojej knižnice Tu uvidíš svoje playlisty Z tvojej zbierky Ďalšie verzie Obľúbené skladby Stiahnuté skladby Playlist je prázdny Naozaj chcete odstrániť všetky skladby z playlistu \"%s\" zo stiahnutých skladieb? Naozaj chcete odstrániť playlist \"%s\"? Skúsiť znova Rádio Prehrávať náhodne Resetovať Podrobnosti Upraviť Spustiť rádio Prehrať Prehrať ďalšie Pridať do fronty Pridať do knižnice Pridať všetko do knižnice Odstrániť z knižnice Odstrániť všetko z knižnice Stiahnuť Sťahuje sa Odstrániť stiahnuté Importovať playlist Pridať do playlistu Zobraziť umelca Zobraziť album Obnoviť Zdieľať Vymazať Odstrániť z histórie Odstrániť z playlistu Odstrániť z fronty Vyhľadať online Synchronizovať Pokročilé Tempo a výška tónu Dátum pridania Názov Interpret Rok Počet skladieb Dĺžka Čas prehrávania Názov skladby Vlastné poradie ID médiá Typ MIME Kodeky Rýchlosť prenosu Vzorkovacia frekvencia Hlasitosť Úroveň hlasitosti Veľkosť súboru Neznámy Kopírované do schránky Upraviť text piesne Hľadať text piesne Upraviť skladbu Interpreti skladby Názov skladby nemôže byť prázdny. Interpret skladby nesmie byť prázdny. Uložiť Vybrať playlist Upraviť playlist Vytvoriť playlist Názov playlistu Názov playlistu nemôže byť prázdny. Upraviť interpreta Meno interpreta Názov interpreta nesmie byť prázdny. Duplikáty Preskočiť duplikáty Pridať aj tak Skladba už je vo vašom playliste %d skladieb je už vo vašom playliste %d skladba %d skladby %d skladieb %d skladieb %d interpret %d interpreti %d interpretov %d interpretov %d album %d albumy %d albumov %d albumov %d playlist %d playlisty %d playlistov %d playlistov %d týždeň %d týždne %d týždňov %d týždňov %d mesiac %d mesiace %d mesiacov %d mesiacov %d rok %d roky %d rokov %d rokov Playlist bol importovaný „%s“ bol odstránený z playlistu Playlist bol synchronizovaný Vrátiť späť Text piesne sa nenašiel Časovač vypnutia Koniec skladby %d minúta %d minúty %d minút %d minút Žiadny stream nie je k dispozícii Bez pripojenia na internet Vypršal čas Neznáma chyba Páči sa mi Páči sa mi všetko Zrušiť „Páči sa mi“ Zrušiť všetky „Páči sa mi“ Náhodné prehrávanie zapnuté Náhodné prehrávanie vypnuté Režim opakovania vypnutý Opakovať aktuálnu skladbu Opakovať frontu Všetky skladby Vyhľadané skladby Prehrávač hudby Nastavenia Vzhľad Téma Povoliť dynamickú tému Tmavý režim Zapnuté Vypnuté Podľa systému Úplná čierna Prispôsobiť navigačné karty Prehrávač Zarovnanie textu prehrávača Pozícia textu piesne Bočné Vľavo Na stred Vpravo Štýl posuvníka prehrávača Predvolené Vlnovkové Rôzne Predvolená otvorená karta Veľkosť bunky mriežky Malé Veľké Kontent Odhlásiť sa Prihlásiť sa Prihlásenie Nie ste prihlásený Prihlásenie zlyhalo Predvolený jazyk obsahu Predvolená krajina obsahu Predvolené systémom Povoliť proxy Typ proxy URL proxy Pre prejavenie zmien reštartujte Prehrávač a zvuk Kvalita zvuku Automaticky Vysoká Nízka Fronta Trvalá fronta Obnoviť poslednú frontu pri spustení aplikácie Automatické načítanie ďalších skladieb Automaticky pridávať ďalšie skladby, keď sa dosiahne koniec fronty, ak je to možné Preskočiť ticho Normalizácia zvuku Automaticky preskočiť na ďalšiu skladbu, keď nastane chyba Zabezpečte plynulé prehrávanie Zastaviť hudbu pri vymazaní úlohy Ekvalizér Úložisko Medzipamäť Medzipamäť obrázkov Medzipamäť skladieb Maximálna veľkosť medzipamäte Neobmedzené Vymazať všetky stiahnuté súbory Maximálna veľkosť medzipamäte obrázkov Vymazať medzipamäť obrázkov Maximálna veľkosť medzipamäte skladieb Vymazať medzipamäť skladieb Použité: %s Súkromie História počúvania Pozastaviť históriu počúvania Vymazať históriu počúvania Naozaj chcete vymazať celú históriu počúvania? História vyhľadávania Pozastaviť históriu vyhľadávania Vymazať históriu vyhľadávania Naozaj chcete vymazať celú históriu vyhľadávania? Použiť prihlásenie na prehliadanie obsahu Toto môže ovplyvniť, aký obsah uvidíte, napríklad zobrazí len prémiové albumy, ak ste prihlásený pomocou účtu Zakázať snímky obrazovky Keď je táto možnosť zapnutá, snímky obrazovky a zobrazenie aplikácie v nedávnych položkách sú zakázané. Povoliť poskytovateľa textov LrcLib Povoliť poskytovateľa textov KuGou Skryť nevhodný obsah Záloha a obnova Zálohovať Obnoviť Importovaný zoznam skladieb Záloha bola úspešne vytvorená Nepodarilo sa vytvoriť zálohu Nepodarilo sa obnoviť zálohu Integrácia Discordu Metrolist používa knižnicu KizzyRPC na nastavenie stavu vášho účtu Discord. To zahŕňa použitie pripojenia Discord Gateway, čo môže byť považované za porušenie TOS Discordu. Zatiaľ však nie sú známe prípady pozastavenia účtov používateľov z tohto dôvodu. Používanie na vlastné riziko.\n\nMetrolist bude extrahovať iba váš token, všetko ostatné sa ukladá lokálne. Zatvoriť Možnosťi Náhľad Zapnúť Rich Presence Informácie Verzia aplikácie Nová verzia je dostupná Prekladové modely Jasné modely prekladu ================================================ FILE: app/src/main/res/values-sl/metrolist_strings.xml ================================================ Lokalni Oddaljeno Grafikoni Nazaj Naslovnica albuma Najboljši glasbeni videoposnetki Trendi Tedni Meseci Leta Neprekinjeno Priljubljeni Preneseno Moj top Predpomnjeno Sinhronizacija seznama predvajanja Sinhronizacija onemogočena Opomba: To omogoča sinhronizacijo z aplikacijo YouTube Music. Tega kasneje NI mogoče spremeniti. Generiranje slike Prosim, počakajte Prekliči Deli besedila pesmi Deli kot besedilo Deli kot sliko Maksimalna meja izbire Deli izbrano Prilagajanje barv Barva besedila Barva sekundarnega besedila Barva ozadja Odstrani iz predpomnilnika Kopiraj povezavo Izberi vse Všečkaj vse Označiti vse kot nepriljubljeno Datum posodobitve Povezava kopirana v odložišče Besedila Že v seznamu predvajanja: %dkrat %dkrat %dkrat %dkrat Podobna vsebina Slog ozadja predvajalnika Sledi temi Gradient Nova oblika predvajalnika Zameglitev Barve gumbov predvajalnika Privzeto Omogoči poteg za menjavo skladbe Če želite pesem dodati v čakalno vrsto, jo podrsnite v levo, če jo želite predvajati naslednjo, pa v desno Spremeni besedila ob kliku Samodejno pomikanje besedil Romaniziraj japonska besedila Romaniziraj korejska besedila Tanek Tanka spodnja navigacijska vrstica Avtomatski seznami predvajanja Prikaži seznam predvajanja \"Všeč mi je\" Prikaži seznam predvajanja \"Preneseno\" Prikaži seznam predvajanja \"Top\" Prikaži seznam predvajanja \"Predpomnjeno\" Prijava s tokenom Tapnite za prikaz tokena Ponovno tapnite za kopiranje ali urejanje To je NAPREDEN način prijave. Namesto spletnega portala lahko tukaj neposredno vnesete ali posodobite svoj prijavni token. S tem lahko na primer pospešite prijavo v več napravah. Upoštevajte, da vsi neveljavni formati tokenov, ki jih aplikacija ne uspe razčleniti, ne bodo sprejeti Samodejna sinhronizacija z računom Več vsebine Splošno Proxy Sprememba privzetega knjižničnega čipa Prilagajanje hitrih izbir Na podlagi zadnje poslušane pesmi Jezik aplikacije Omogoči podobno vsebino Samodejno dodajanje podobnih skladb, ko je dosežen konec čakalne vrste %d%% Uvoz seznamov predvajanja \"m3u\" Uvoz seznamov predvajanja \"csv\" Opomba: Dodajanje lokalnih skladb na sinhronizirane/oddaljene sezname predvajanja ni podprto. Vsaka druga kombinacija je veljavna Samodejno prenašanje ob všečkanju Samodejno prenašanje pesmi ob všečkanju Občutljivost premikanja mini predvajalnika %1$d%% Ali ste prepričani, da želite počistiti vse pesmi v predpomnilniku? Ali ste prepričani, da želite počistiti vse slike v predpomnilniku? Ali ste prepričani, da želite počistiti vse prenose? Onemogoči Niste prijavljeni na YouTube Odpri podprte povezave Ni bilo mogoče odpreti nastavitev aplikacije Opombe ob izdaji Ves čas Zadnjih 24 ur Pretekli teden Pretekli mesec Preteklo leto Dolžina mojega seznama Top Trajanje zgodovine Informacija Opis Ogledi Všečki Ne Všeč Naroči se Naročeni ste %d sekunda %d sekundi %d sekunde %d sekund Nova oblika mini predvajalnika Igra zdaj Zapri Skrij predogled predvajalnika Zamenjaj podobo albuma z logotipom aplikacije v predvajalniku +%1$d sekund naprej -%1$d sekund nazaj Napredno iskanje Če je omogočeno, se ob vsakem preskoku časa pri iskanju postopoma doda dodatnih 5 sekund Onemogoči samodejno nalaganje, ko je vklopljeno ponavljanje vseh pesmi Ne nalagaj samodejno več pesmi in podobne vsebine, ko je vklopljen način ponavljanja vseh Naloženo Naloženo O izvajalcu Prikaži več Prikaži manj Stran izvajalca Prikaži opis izvajalca Prikaži število naročnikov Prikaži število mesečnih poslušalcev Prenesi vse pesmi za predvajanje brez povezave Odstrani vse prenesene pesmi s tega seznama predvajanja Prenos je v teku Deli ta seznam predvajanja z drugimi Trajno odstrani ta seznam predvajanja Sinhroniziraj seznam predvajanja z YouTube Music Zaganjanje radia Obreži sliko albuma Vsili kvadratno razmerje stranic z obrezovanjem sličic videoposnetkov Primarna barva Terciarna barva Valovit Podrsaj pesem, da jo odstraniš s seznama predvajanja Omogoči svetleč učinek besedila Dodaj svetlečo animacijo in učinek poskakovanja aktivnemu besedilu Omogoči Better Lyrics Uporabi ponudnika Better Lyrics za besedila, sinhronizirana po besedah Omogoči SimpMusic Lyrics Uporabi ponudnika SimpMusic Lyrics za sinhronizirana besedila Ponovno sinhroniziraj Prikaži seznam predvajanja \"Naloženo\" Najprej premešaj seznam predvajanja/album Pri mešanju najprej predvajaj vse pesmi z izvirnega seznama predvajanja/albuma, nato pa podobno vsebino Prikaži kartico Wrapped Hitro previjaj skozi tihe dele pesmi Takoj preskoči tišino Preskoči naprej med tihimi trenutki namesto pospeševanja predvajanja Uredi naslovnico seznama predvajanja Vmesnik Zasebnost in varnost Obvestila o novih različicah Uporabi podrobnosti namesto statusa Izpostavi naslov pesmi namesto izvajalca Integracije Uporabniško ime Geslo Integracija z Last.fm Omogoči beleženje Pošlji status trenutnega predvajanja Nastavitve beleženja Beleži pesmi, daljše od Odstotek zakasnitve beleženja Zakasnitev beleženja v minutah Pošlji všečke/nevšečke Dodaj/odstrani pesmi med priljubljenimi na Last.fm, ko jih všečkaš/odvšečkaš v Metrolistu Romaniziraj kitajska besedila Opomba: Vaš račun mora biti povezan s telefonsko številko in preverjen na YouTube Music, da lahko spremenite naslovnico seznama predvajanja. Predvajalnik in vsebina Shramba in podatki Sistem in o programu Konfiguriraj posredniški strežnik (proxy) Uporabniško ime za posredniški strežnik (proxy) Geslo posredniškega strežnika (proxy) Omogoči preverjanje pristnosti Cirilica Romanizacija Romanizacija besedil Romaniziraj ruska besedila Romaniziraj ukrajinska besedila Romaniziraj beloruska besedila Romaniziraj kirgiška besedila Romaniziraj srbska besedila Romaniziraj bolgarska besedila EKSPERIMENTALNO: Zaznaj jezik po vrsticah Jezik v cirilici bo zaznan po vrsticah namesto na podlagi celotne pesmi. Ali ste prepričani? To je eksperimentalna funkcija.\n\nPrivzeto se jezik določi na podlagi celotne pesmi, z vklopom te možnosti pa se bo določil po vrsticah. To bo omogočilo delovanje večjezičnih pesmi, VENDAR jezik morda ne bo vedno pravilen (na primer: če ukrajinsko besedilo ne vsebuje črk, značilnih za ukrajinščino, se lahko romanizira kot ruščina).\n\nČe nimate težav, priporočamo, da to možnost pustite izklopljeno. Romaniziraj trenutno skladbo Po izbiri slike počakajte trenutek, da se na seznamu predvajanja prikaže nova naslovnica. Omogoči razbremenitev Za predvajanje zvoka uporabi razbremenjeno zvočno pot. Izklop te možnosti lahko poveča porabo energije, vendar je lahko koristen, če imate težave s predvajanjem zvoka ali z naknadno obdelavo Izberi iz knjižnice Odstrani naslovnico po meri Romaniziraj makedonska besedila Posodobitve Samodejno preverjanje posodobitev Omogoči obvestila o posodobitvah Na voljo je posodobitev Posodobitve aplikacije Skrij videospote Google Cast Omogoči predvajanje zvoka na Chromecast in druge naprave s funkcijo Cast Prikaži podatke o skladbi Spremeni naslov ali izvajalca Ustvari radijsko postajo na podlagi tega elementa Dodaj na vrh čakalne vrste Dodaj na konec čakalne vrste Shrani v knjižnico Omogoči predvajanje brez povezave Dodaj na enega od seznamov predvajanja Pridobi najnovejše metapodatke iz YouTube Music Deli povezavo do tega elementa Trajno odstrani ta element Spremeni tempo in višino tona skladbe Prilagodi izenačevalnik zvoka Omogoči dinamično ikono Mini predvajalnik Popolnoma črn mini predvajalnik Pozor! Izbrali ste omejitev velikosti predpomnilnika, ki je manjša od tega, kar aplikacija trenutno uporablja (%1$s). Če nadaljujete, lahko aplikacija odstrani nekaj predpomnjenih %2$s, da bo ustrezala novi omejitvi. Želite vseeno nadaljevati? Nadaljuj Prijavljanje… Slog animacije besedo za besedo Brez Bledenje Sij Drsenje Karaoke Apple Music Velikost besedila Razmik med vrsticami besedila Naslovnica albuma za %s Poslušali ste edinstvenih albumov Vaš najboljši album je Vaš osebni seznam predvajanja je pripravljen Vaših najboljših 5 albumov Ta album ste poslušali %d minut %d minut Ni podatkov Vaši najboljši izvajalci leta %d minut Tvoje najboljše skladbe leta Naslovnica albuma Tvoj najboljši izvajalec leta je Slika najboljšega izvajalca Poslušal si ga %d minut Tvoja največkrat predvajana skladba je Poslušal si jo %d minut Poslušal si različnih izvajalcev Predvajanje na %s Napredek: %s%% Poslušaš Metrolist ================================================ FILE: app/src/main/res/values-sl/strings.xml ================================================ Dom Skladbe Izvajalci Albumi Seznami predavanja %d izbran %d izbrana %d izbrani %d izbranih Zgodovina Statistika Razpoloženje in žanri Račun Hitri izbori Poslušajte skladbe in ustvarite hitre izbire Pozabljene priljubljene Vaši seznami predvajanja YouTube Podobno kot Novi izdani albumi Danes Včeraj Ta teden Prejšnji teden Najbolj predvajane skladbe Najbolj predvajani izvajalci Najbolj predvajani albumi Išči Iskanje v YouTube Music… Iskanje v knjižnici… Knjižnica Všečkano Preneseno Vse Nadaljuj z poslušanjem Glasbe Video Albumi Izvajalci Seznami predvajanja ================================================ FILE: app/src/main/res/values-sv/metrolist_strings.xml ================================================ Skrolla låttexter automatiskt Tryck igen för att kopiera eller redigera Byt förvalt bibliotekschip Allmänt Proxy Ställ in snabbval Baserat på den senast lyssnade låten Appspråk Lägg automatiskt till liknande låtar när kön tar slut %d%% Ladda ner automatiskt vid gilla Historikens längd Aktivera liknande innehåll 1 sekund %d sekunder Importera M3U-spellista Ladda automatiskt ner låtar när du gillar dem Kunde inte öppna appinställningar Senaste 24 timmarna All tid Information Beskrivning Visningar Gillningar Ogillningar Bakgrundsfärg Dela som text Visa spellistan \"Gillade\" Dela som bild Längd på min topplista Visa spellistan \"Nedladdade\" Senaste veckan Importera \"csv\" spellista Är du säker på att du vill rensa alla cachade låtar? Detta är en AVANCERAD inloggningsmetod. Som ett alternativ till webbportalen kan du ange eller uppdatera din inloggningstoken här. Detta kan till exempel göra det snabbare att logga in på flera enheter. Observera att ogiltiga token-format som appen inte kan tolka kommer att nekas Obs! Att lägga till lokala låtar i synkade/fjärrspellistor stöds inte. Alla andra kombinationer fungerar Senaste månaden Senaste året Tillbaka Lokal Fjärrhistorik Listor Skivomslag Populärt just nu Veckor Månader År Oavbruten Nedladdade Gillade Mina topp Cachade Synka spellista Synkning inaktiverad Obs! Det här möjliggör synkronisering med YouTube Music. Det går INTE att ångra i efterhand. Skapar bild Vänligen vänta Avbryt Dela låttexter Maximalt antal val Dela valda Anpassa färger Textfärg Sekundär textfärg Populära musikvideor Dölj texter i nedersta menyn Automatiska spellistor Ta bort från cache Kopiera länk Välj alla Gilla alla Ogilla alla Uppdateringsdatum Länk kopierad Låttext Redan i spellistan: %d gång %d gånger Liknande innehåll Utseende på spelarens bakgrund Följ tema Färgövergång Suddighet Färger på spelarens knappar Standard Aktivera svepning för att byta låt Svep låten till vänster för att lägga den i kön eller till höger för att spela den härnäst Ändra låttext genom att klicka Smal Visa spellistan \"Topp\" Visa spellista \"Cachade\" Avancerad inloggning (token) Tryck för att visa token Öppna supporterade länkar Är du säker på att du vill rensa alla nedladdningar? Inte inloggad på YouTube Versionsinfo Ny spelar design Ny mini spelar design Upladdad Upladdad Om artisten Visa mer Visa mindre Artistens sida Visa information om artisten Visa följare Visa månatliga lysnare Ladda ner alla låtar för att lyssna offline Ta bort alla laddade låtar fråm den här spellista Hämtningen pågår Dela den här spellista med andra Radera den här spellista för alltid Synka spellistan med YouTube Music Börjar spela radio Spelar nu Stäng Dölj spelarens miniatyrbild Ersätta album bilder med appens logo på spelaren Skära album bild +%1$d skunder framåt -%1$d sekunder bakåt Progresivt seek När på, tilläger 5 sekunder gradvis efter varje seek skip Primär färg Tertiär färg Viftande Swipe låten för att ta den bort från spellistan Slå på glödande effekt på låttexter Tilläg glödande animation och stunsade effekt till akriva låttexter Slå på Better Lyrics Använd Better Lyrics provinatör för att ha ord för ord synkade låttexter Slå på SimpMusic Lyrics Använd SimpMusic Lyrics provintör för att ha synkat låttexter Synka en gång till Visa spellista \"Uppladdade\" Blanda spellistan/albumet först ================================================ FILE: app/src/main/res/values-ta/metrolist_strings.xml ================================================ பட்டியல்கள் பின்செல் ஆல்பம் கவர் சிறந்த இசை வீடியோக்கள் டிரெண்டிங் பதிவிறக்கம் செய்யப்பட்டது மாதங்கள் வாரங்கள் ஆண்டுகள் தொடர்ச்சி பிடித்திருந்தது என் சிறந்த பதுக்கம் செய்யப்பட்ட அகம் புறம் ஒத்திசைவு பாடல்கள் ஒத்திசைவு முடக்கப்பட்டுள்ளது பதிவேற்றப்பட்டது பதிவேற்றப்பட்டது குறிப்பு: இது YouTube Music உடன் ஒத்திசைக்க அனுமதிக்கிறது. இதை பின்னர் மாற்ற முடியாது. படத்தை உருவாக்குகிறது தயவுசெய்து காத்திருக்கவும் ரத்துசெய் இயக்கு பாடல் வரிகளைப் பகிரவும் உரையாகப் பகிரவும் படமாகப் பகிரவும் அதிகபட்ச தேர்வு வரம்பு தேர்ந்தெடுத்தவற்றைப் பகிர் வண்ணங்களைத் தனிப்பயனாக்குங்கள் ================================================ FILE: app/src/main/res/values-ta/strings.xml ================================================ வீடு பாடல்கள் கலைஞர்கள் விரைவான தேர்வுகள் மறந்துபோன பிடித்தவை அதிகம் விளையாடிய கலைஞர்கள் அதிகம் விளையாடிய ஆல்பங்கள் தேடல் பதிவிறக்கம் அனைத்தும் பாடல்கள் வீடியோக்கள் ஆல்பம் கலைஞர்கள் பிளேலிச்ட்கள் சமூக பிளேலிச்ட்கள் பிளேலிச்ட்டை இறக்குமதி செய்யுங்கள் பிளேலிச்ட்டில் சேர்க்கவும் கலைஞரைக் காண்க ஆல்பத்தைக் காண்க ரீஃபெட்ச் பங்கு நீக்கு ஆன்லைனில் தேடுங்கள் ஒத்திசை கலைஞர் ஆண்டு பாடல் எண்ணிக்கை நீளம் விளையாடும் நேரம் மாதிரி வீதம் உரித்தல் தொகுதி பாடல் தலைப்பு பாடல் கலைஞர்கள் பாடல் தலைப்பு காலியாக இருக்க முடியாது. பாடல் கலைஞர் காலியாக இருக்க முடியாது. சேமி %d பாடல்கள் ஏற்கனவே உங்கள் பிளேலிச்ட்டில் உள்ளன %d பாடல் %d பாடல்கள் %d கலைஞர் %d கலைஞர்கள் %d ஆல்பம் %d ஆல்பங்கள் %d பிளேலிச்ட் %d பிளேலிச்ட்கள் %d வாரம் %d வாரங்கள் %d மாதம் %d மாதங்கள் %d ஆண்டு %d ஆண்டுகள் பிளேலிச்ட் இறக்குமதி செய்யப்பட்டது பிளேலிச்ட் ஒத்திசைக்கப்பட்டது நேரம் முடிந்தது தெரியாத பிழை போன்ற எல்லாவற்றையும் போல அகற்றவும் எல்லா விருப்பங்களையும் அகற்று கலக்கு கலக்கவும் அனைத்து பாடல்களும் தேடியது பாடல்கள் இருண்ட கருப்பொருள் ஆன் அணை அமைப்பைப் பின்தொடரவும் வழிசெலுத்தல் தாவல்களைத் தனிப்பயனாக்குங்கள் வீரர் இதர இயல்புநிலை திறந்த தாவல் இயல்புநிலை உள்ளடக்க மொழி இயல்புநிலை உள்ளடக்க நாடு கணினி இயல்புநிலை ப்ராக்சியை இயக்கவும் பதிலாள் வகை வரிசை தொடர்ச்சியான வரிசை ஆட்டோ ஏற்றும் கூடுதல் பாடல்கள் பாடல் கேச் அதிகபட்ச கேச் அளவு ச்கிரீன்சாட்டை முடக்கு மீட்டெடு மொழிபெயர்ப்பு மாதிரிகளை அழிக்கவும் ஆல்பம் பிளேலிச்ட்கள் வரலாறு புள்ளிவிவரங்கள் மனநிலை மற்றும் வகைகள் கணக்கு உங்கள் விரைவான தேர்வுகளை உருவாக்க பாடல்களைக் கேளுங்கள் கேட்டுக்கொண்டே இருங்கள் உங்கள் யூடியூப் பிளேலிச்ட்கள் ஒத்த புதிய வெளியீட்டு ஆல்பங்கள் இன்று நேற்று இந்த வாரம் கடந்த வாரம் பெரும்பாலான பாடல்கள் யூடியூப் இசையைத் தேடுங்கள்… தேடல் நூலகம்… நூலகம் பிடித்திருந்தது சிறப்பு பிளேலிச்ட்கள் புக்மார்க்கு செய்யப்பட்டது முடிவுகள் எதுவும் கிடைக்கவில்லை நூலக பாடல்கள் இங்கே காண்பிக்கப்படும் நூலக கலைஞர்கள் இங்கே காண்பிக்கப்படுவார்கள் நூலக ஆல்பங்கள் இங்கே காண்பிக்கப்படும் உங்கள் பிளேலிச்ட்கள் இங்கே காண்பிக்கப்படும் உங்கள் நூலகத்திலிருந்து பிற பதிப்புகள் பாடல்கள் விரும்பின பதிவிறக்கம் செய்யப்பட்ட பாடல்கள் பிளேலிச்ட் காலியாக உள்ளது பதிவிறக்கம் செய்யப்பட்ட பாடல்கள் சேமிப்பகத்திலிருந்து அனைத்து \"%s\" பிளேலிச்ட் பாடல்களையும் அகற்ற விரும்புகிறீர்களா? பிளேலிச்ட்டை \"%s\" நீக்க விரும்புகிறீர்களா? மீண்டும் முயற்சிக்கவும் வானொலி கலக்கு மீட்டமை விவரங்கள் தொகு வானொலியைத் தொடங்கவும் விளையாடுங்கள் அடுத்து விளையாடுங்கள் வரிசையில் சேர்க்கவும் நூலகத்தில் சேர்க்கவும் அனைத்தையும் நூலகத்தில் சேர்க்கவும் நூலகத்திலிருந்து அகற்று அனைத்தையும் நூலகத்திலிருந்து அகற்றவும் பதிவிறக்கம் பதிவிறக்குகிறது பதிவிறக்கத்தை அகற்று வரலாற்றிலிருந்து அகற்று பிளேலிச்ட்டிலிருந்து அகற்று வரிசையிலிருந்து அகற்று மேம்பட்ட டெம்போ மற்றும் சுருதி தேதி சேர்க்கப்பட்டது பெயர் தனிப்பயன் வரிசை மீடியா ஐடி மைம் வகை கோடெக்குகள் பிட்ரேட் கோப்பு அளவு தெரியவில்லை இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது பாடல் திருத்து பாடல் தேடல் பாடலைத் திருத்து பிளேலிச்ட்டைத் தேர்வுசெய்க பிளேலிச்ட்டைத் திருத்து பிளேலிச்ட்டை உருவாக்கவும் பிளேலிச்ட் பெயர் பிளேலிச்ட் பெயர் காலியாக இருக்க முடியாது. கலைஞரைத் திருத்து கலைஞரின் பெயர் கலைஞரின் பெயர் காலியாக இருக்க முடியாது. நகல்கள் நகல்களைத் தவிர்க்கவும் எப்படியும் சேர்க்கவும் பாடல் ஏற்கனவே உங்கள் பிளேலிச்ட்டில் உள்ளது பிளேலிச்ட்டில் இருந்து \"%s\" அகற்றப்பட்டது செயல்தவிர் வரிகள் காணப்படவில்லை தூக்க நேரங்குறிகருவி பாடலின் முடிவு 1 மணித்துளி %d நிமிடங்கள் ச்ட்ரீம் எதுவும் கிடைக்கவில்லை பிணைய இணைப்பு இல்லை பயன்முறையை மீண்டும் செய்யவும் தற்போதைய பாடலை மீண்டும் செய்யவும் வரிசையை மீண்டும் செய்யவும் மியூசிக் பிளேயர் அமைப்புகள் தோற்றம் கருப்பொருள் மாறும் கருப்பொருள் இயக்கவும் தூய கருப்பு பிளேயர் உரை சீரமைப்பு பாடல் உரை நிலை பக்க இடது நடுவண் வலது பிளேயர் ச்லைடர் பாணி இயல்புநிலை மோசமான கட்டம் செல் அளவு சிறிய பெரியது உள்ளடக்கம் புகுபதிவு உள்நுழையவில்லை பதிலாள் முகவரி நடைமுறைக்கு வர மறுதொடக்கம் பிளேயர் மற்றும் ஆடியோ ஆடியோ தகுதி தானி உயர்ந்த குறைந்த பயன்பாடு தொடங்கும் போது உங்கள் கடைசி வரிசையை மீட்டெடுக்கவும் முடிந்தால், வரிசையின் முடிவை எட்டும்போது தானாகவே அதிகமான பாடல்களைச் சேர்க்கவும் ம .னத்தைத் தவிர்க்கவும் ஆடியோ இயல்பாக்கம் பிழை ஏற்படும்போது அடுத்த பாடலுக்கு தானாகத் தவிர்க்கவும் உங்கள் தொடர்ச்சியான பின்னணி அனுபவத்தை உறுதிப்படுத்தவும் பணியில் இசையை நிறுத்துங்கள் சமநிலைப்படுத்தி சேமிப்பு கேச் பட தற்காலிக சேமிப்பு வரம்பற்றது எல்லா பதிவிறக்கங்களையும் அழிக்கவும் அதிகபட்ச பட கேச் அளவு தெளிவான பட தற்காலிக சேமிப்பு அதிகபட்ச பாடல் கேச் அளவு தெளிவான பாடல் தற்காலிக சேமிப்பு %s பயன்படுத்தப்படுகின்றன தனியுரிமை வரலாற்றைக் கேளுங்கள் இடைநிறுத்தப்பட்ட வரலாற்றைக் கேளுங்கள் கேளுங்கள் வரலாற்றைக் கேளுங்கள் எல்லா கேட்கும் வரலாற்றையும் அழிக்க விரும்புகிறீர்களா? தேடல் வரலாறு தேடல் வரலாறு இடைநிறுத்தவும் தேடல் வரலாற்றை அழிக்கவும் எல்லா தேடல் வரலாற்றையும் அழிக்க விரும்புகிறீர்களா? உள்ளடக்கத்தை உலாவுவதற்கு உள்நுழைவைப் பயன்படுத்தவும் நீங்கள் பார்க்கும் உள்ளடக்கத்தை இது பாதிக்கும், எடுத்துக்காட்டாக, நீங்கள் காப்பீடு கணக்கில் உள்நுழைந்துள்ளால் காப்பீடு மட்டும் ஆல்பங்களைக் காட்டுகிறது இந்த விருப்பம் இயக்கத்தில் இருக்கும்போது, ச்கிரீன் சாட்கள் மற்றும் ரெசென்ட்களில் பயன்பாட்டின் பார்வை முடக்கப்பட்டுள்ளன. LRCLIB பாடல் வழங்குநரை இயக்கு குகோ பாடல் வழங்குநரை இயக்கு வெளிப்படையான உள்ளடக்கத்தை மறைக்கவும் காப்புப்பிரதி மற்றும் மீட்டமை காப்புப்பிரதி இறக்குமதி செய்யப்பட்ட பிளேலிச்ட் காப்புப்பிரதி வெற்றிகரமாக உருவாக்கப்பட்டது காப்புப்பிரதியை உருவாக்க முடியவில்லை காப்புப்பிரதியை மீட்டெடுப்பதில் தோல்வி முரண்பாடு ஒருங்கிணைப்பு உங்கள் முரண்பாடு கணக்கின் நிலையை அமைக்க இன்டெர்டூன் கிச்சார்பிசி நூலகத்தைப் பயன்படுத்துகிறது. இது முரண்பாடான நுழைவாயில் இணைப்பைப் பயன்படுத்துவதை உள்ளடக்குகிறது, இது டிச்கார்டின் TOS இன் மீறலாகக் கருதப்படலாம். இருப்பினும், இந்த காரணத்திற்காக பயனர் கணக்குகள் இடைநிறுத்தப்பட்டதாக அறியப்படாத வழக்குகள் எதுவும் இல்லை. உங்கள் சொந்த ஆபத்தில் பயன்படுத்தவும்.\n\n இன்னெர்டூன் உங்கள் கிள்ளாக்கை மட்டுமே பிரித்தெடுக்கும், மற்ற அனைத்தும் உள்நாட்டில் சேமிக்கப்படும். தள்ளுபடி விருப்பங்கள் முன்னோட்டம் உள்நுழைவு தோல்வியடைந்தது வெளியேறு பணக்கார இருப்பை இயக்கவும் பற்றி பயன்பாட்டு பதிப்பு புதிய பதிப்பு கிடைக்கிறது மொழிபெயர்ப்பு மாதிரிகள் %d தேர்ந்தெடுக்கப்பட்டது %d தேர்ந்தெடுக்கப்பட்டன புகுபதிகை ================================================ FILE: app/src/main/res/values-te/metrolist_strings.xml ================================================ స్థానిక దూరస్థ పట్టికలు వెనుకకు ఆల్బమ్ ముఖచిత్రం అగ్ర సంగీత చిత్రాలు ప్రాచుర్యంలో ఉన్నవి వారాలు నెలలు సంవత్సరాలు నిరంతరం నచ్చినవి దింపబడినవి నా అగ్రస్థానంలో ఉన్నవి తాత్కాలిక నిల్వ జాబితాను సమకాలీకరించు సమకాలీకరణ నిలిపివేయబడింది గమనిక: ఇది యూట్యూబ్ మ్యూజిక్‌తో సమకాలీకరించడానికి అనుమతిస్తుంది. దీనిని తర్వాత మార్చడం సాధ్యం కాదు. చిత్రాన్ని సృష్టిస్తోంది దయచేసి వేచి ఉండండి రద్దు చేయి పద్యాలను పంచుకోండి పాఠ్యంలా పంచుకోండి చిత్రంలా పంచుకోండి గరిష్ట ఎంపిక పరిమితి ఎంచుకున్నవి పంచుకోండి రంగులను అనుకూలించండి వచన రంగు ద్వితీయ వచన రంగు నేపథ్య రంగు తాత్కాలిక నిల్వ నుండి తొలగించు లింక్‌ను కాపీ చేయి అన్నింటిని ఎంచుకోండి అన్నింటినీ నచ్చినవిగా గుర్తించు అన్నింటినీ నచ్చనివిగా గుర్తించు నవీకరించబడిన తేదీ లింక్ క్లిప్‌బోర్డుకు కాపీ చేయబడింది సంగీత కేంద్రం ప్రారంభమవుతోంది ఇప్పుడు వింటున్నది మూసివేయి చిత్రపటాన్ని చిన్నచిత్రాన్ని దాచు ఆల్బమ్ చిత్రాన్ని యాప్ లోగోతో భర్తీ చేయి ఇప్పటికే జాబితాలో ఉంది: %d సారి %d సార్లు +%1$d సెకన్లు ముందుకు -%1$d సెకన్లు వెనుకకు క్రమంగా ముందుకు/వెనక్కి జరగడం గీతం ఇదే విధమైన అంశాలు వాయించే తెర నేపథ్య శైలి రూపకల్పనను అనుసరించు రంగుల సమ్మేళనం కొత్త వాయించే తెర రూపకల్పన కొత్త చిన్న వాయించే తెర రూపకల్పన మసకబార్చు వాయించే తెర మీటల రంగులు డిఫాల్ట్ సక్రియమైనట్లయితే, ప్రతి సీక్ స్కిప్‌కి 5 అదనపు సెకండ్లను క్రమంగా జోడిస్తుంది ================================================ FILE: app/src/main/res/values-te/strings.xml ================================================ తిరిగి మొదటికి కళాకారులు సవరించు ఎక్కువగా వినిన పాటలు అన్ని చరిత్ర గణాంకాలు ఖాతా మరచిపోయిన మదురాలు కొత్త చిత్రాలు ఈరోజు నిన్న ఈ వారం ముందు వారం ఎక్కువగా వినిన కళాకారులు ఎక్కువగా వినిన చిత్రాలు వెతుకు నచ్చినవి పాటలు వీడియో చిత్రాలు కళాకారులు నచ్చిన పాటలు మళ్ళీ ప్రయత్నించండి ఇల్లు వివరాలు వరుసలోకి జోడించు కళాకారుల వివరాలు చూడు చిత్రాల వివరాలు చూడు షేర్ తోలగించు చరిత్ర నుంచి తొలగించు పేరు కళాకారుడు సంవత్సరం పాట నిడివి శబ్దం తెలియనిది పాటని సవరించండి పాట పాడినవారు పాట గాయకులు కాళీ గా ఉండకూడదు. గాయకులని మార్చు గాయకుల పేర్లు %d సంవత్సరం %d ఏళ్లు పాట ముగిసింది సమయం ముగిసింది తేలియని లోపం %d పాట %dపాటలు ఇష్టం అన్ని ఇష్టం ఇష్ట పడిన వాటిని తోలగించు ఇష్ట పడినవి అన్ని తోలగించు అన్ని పాటలు వెతికిన పాటలు ఎడమ మద్యలో కుడి వైపు మిగిలినవి చిన్నది పెద్దది తక్కువ నిశ్శబ్దాన్ని దాటవేయి నిల్వ అపరిమితం ఎంపికలు గురించి పాటలు వింటూ ఉండండి తరువాతి పాట మరలా సోదించు పొడవు పాట పేరు ఏమి దొరకలేదు మొదలు పెట్టు జోడించిన తేదీ పాటల లెక్క ఒక నిమిషం %d నిమిషాలు పాట పేరు కాళీ గా ఉండకూడదు. గాయకుల పేర్లు కాళీగా ఉంచకూడదు. ఈ పాటని మళ్ళీ వినిపించు వరుస ఎక్కువ పాటలు వినిన చరిత్ర ఆల్బమ్‌లు ప్లేలిస్ట్‌లు %d ఎంచుకోబడింది %d ఎంచుకోబడినవి మూడ్ మరియు కళా ప్రక్రియలు త్వరిత ఎంపికలు మీ త్వరిత ఎంపికలను రూపొందించడానికి పాటలను వినండి మీ యూట్యూబ్ప్లేజాబితాలు దీనికి సమానమైనది యూట్యూబ్ మ్యూజిక్‌లో వెతకండి… లైబ్రరీలో వెతకండి… లైబ్రరీ డౌన్‌లోడ్ చేయబడింది ప్లేలిస్ట్‌లు కమ్యూనిటీ ప్లేలిస్ట్‌లు ఫీచర్ చేసిన ప్లేలిస్ట్‌లు డౌన్లోడ్ డౌన్‌లోడ్ అవుతోంది డౌన్‌లోడ్ ని తొలగించు ప్లేజాబితా దిగుమతి చేయండి ప్లేలిస్ట్ లో వేసుకోండి ప్లేలిస్ట్ నుండి తీసివేయండి లైబ్రరీ నుండి అన్నీ తీసివేయి ఆన్‌లైన్‌లో శోధించండి సమకాలీకరించు ముందుకు టెంపో మరియు పిచ్ అనుకూల క్రమం మీడియా ఐడి మైమ్ రకం కోడెక్‌లు మాదిరి రేటు శబ్ద తీవ్రత క్లిప్బోర్డ్కు కాపీ చేయబడింది సాహిత్యాన్ని సవరించండి సాహిత్యాన్ని వెతకండి భద్రపరుచుకోండి ప్లేలిస్ట్‌ని ఎంచుకోండి ప్లేలిస్ట్ను సవరించండి ప్లేలిస్ట్ను సృష్టించుకోండి ప్లేలిస్ట్ను పేరు ప్లేలిస్ట్ను పేరు ఖాళీగా ఉండకూడదు. నకిలీలు నకిలీలను డేటివేయాండి ఏదేమైనా జోడించు మీ ప్లేలిస్ట్‌లో పాట ఇప్పటికే ఉంది %d పాటలు ఇప్పటికే మీ ప్లేజాబితాలో ఉన్నాయి %d కళాకారుడు %d కళాకారులు %d సంకలనం %d సంకలనాలు %d ప్లేలిస్ట్ %d ప్లేలిస్ట్‌లు %d వారం %d వారాలు %d నెల %d నెలలు ప్లేలిస్ట్ దిగుమతి చేయబడింది ప్లేలిస్ట్‌ నుండి \"%s\" తీసివేయబడింది ప్లేలిస్ట్‌లు సమకాలీకరించబడింది అన్డు సాహిత్యం కనుగొనబడలేదు నిద్ర టైమర్ నెట్వర్క్ కనెక్షన్ లేదు కలాపటం ఆన్ చేయబడిండి కలాపటం ఆఫ్ చేయబడిండి పునరావృత విధానం ఆఫ్ చేయబడిండి పునరావృత వరుస సంగీత ప్లేయర్ సెట్టింగ్‌లు ప్రదర్శన థీమ్ బుక్‌మార్క్ చేయబడింది లైబ్రరీ పాటలు ఇక్కడ కనిపిస్తాయి లైబ్రరీ లో కళాకారులు ఇక్కడ కనిపిస్తారు లైబ్రరీ ఆల్బమ్‌లు ఇక్కడ కనిపిస్తాయి మీ ప్లేలిస్ట్‌లు ఇక్కడ కనిపిస్తాయి మీ లైబ్రరీ నుండి ఇతర వెర్షన్లు డౌన్‌లోడ్ చేసిన పాటలు ప్లేలిస్ట్ ఖాళీగా ఉంది డౌన్‌లోడ్ చేసిన పాటల నిల్వ నుండి అన్ని \"%s\" ప్లేలిస్ట్ పాటలను మీరు నిజంగా తీసివేయాలనుకుంటున్నారా? మీరు నిజంగా \"%s\" ప్లేలిస్ట్ తొలగించాలనుకుంటున్నారా? ఆకాశవాణి కలాపటం ఆకాశవాణి నీ మొదళ్ళు పెట్టండి లైబ్రరీకి జోడించు లైబ్రరీకి అన్నీ జోడించండి లైబ్రరీకి అన్నీ జోడించండి క్యూ నుండి తొలగించండి బిట్రేట్ ఫైల్ పరిమాణం సంగీతం దొరకలేదు డైనమిక్ థీమ్‌ని ఎనేబుల్ చేయండి డార్క్ థీమ్ ఆన్ ఆఫ్ ఫాలో సిస్టమ్ స్వచ్ఛమైన నలుపు నావిగేషన్ ట్యాబ్‌లను అనుకూలపరచండి ప్లేయర్ ప్లేయర్ వచన సమలేఖనం సాహిత్యం వచన స్థానం పక్కాకి ప్లేయర్ స్లయిడర్ శైలి మముల్గా స్క్విగ్లీ డిఫాల్ట్ తెరిచిన ట్యాబ్ గ్రిడ్ సెల్ సైజు విషయం లాగ్ అవుట్ లాగిన్ లాగిన్ లాగిన్ కాలేదు లాగిన్ విఫలమైంది డిఫాల్ట్ కంటెంట్ భాష డిఫాల్ట్ కంటెంట్ దేశం సిస్టమ్ డిఫాల్ట్ ప్రాక్సీ ప్రారంభించు ప్రాక్సీ రకం ప్రాక్సీ URL ప్రభావం చూపేందుకు పునఃప్రారంభించండి ప్లేయర్ మరియు ఆడియో ఆడియో నాణ్యత స్వీయ నిరంతర క్యూ యాప్ ప్రారంభమైనప్పుడు మీ చివరి క్యూను పునరుద్ధరించండి మరిన్ని పాటలను ఆటో లోడ్ చేయి వీలైతే, క్యూ ముగింపుకు చేరుకున్నప్పుడు మరిన్ని పాటలను స్వయంచాలకంగా జోడించండి ఆడియో సాధారణీకరణ లోపం సంభవించినప్పుడు తదుపరి పాటకు స్వయంచాలకంగా దాటవేయి మీ నిరంతర ప్లేబ్యాక్ అనుభవాన్ని నిర్ధారించుకోండి టాస్క్ క్లియర్‌లో మ్యూజిక్ ఆపండి సమానంగా కాష్ చిత్రం కాష్ పాట కాష్ గరిష్ట కాష్ పరిమాణం అన్ని డౌన్‌లోడ్‌లను క్లియర్ చేయి గరిష్ట చిత్రం కాష్ పరిమాణం చిత్రం కాష్‌ను క్లియర్ చేయండి గరిష్ట పాట కాష్ పరిమాణం పాట కాష్‌ను క్లియర్ చేయండి %s ఉపయోగించబడింది గోప్యత విన్న చరిత్రను పాజ్ చేయండి వినే చరిత్రను క్లియర్ చేయండి మీరు ఖచ్చితంగా మొత్తం వినే చరిత్రను క్లియర్ చేయాలనుకుంటున్నారా? శోధన చరిత్ర శోధన చరిత్రను పాజ్ చేయండి శోధన చరిత్రను క్లియర్ చేయండి మీరు ఖచ్చితంగా మొత్తం శోధన చరిత్రను క్లియర్ చేయాలనుకుంటున్నారా? కంటెంట్ బ్రౌజింగ్ కోసం లాగిన్ ఉపయోగించండి ఇది మీరు చూసే కంటెంట్‌ను ప్రభావితం చేస్తుంది మరియు ఉదాహరణకు మీరు ప్రీమియం ఖాతాతో లాగిన్ అయి ఉంటే ప్రీమియం-మాత్రమే ఆల్బమ్‌లను చూపుతుంది స్క్రీన్‌షాట్‌ను నిలిపివేయండి ఈ ఎంపిక ఆన్‌లో ఉన్నప్పుడు, స్క్రీన్‌షాట్‌లు మరియు ఇటీవలి వాటిలో యాప్‌ల వీక్షణ నిలిపివేయబడతాయి. LrcLib లిరిక్స్ ప్రొవైడర్‌ను ప్రారంభించండి KuGou లిరిక్స్ ప్రొవైడర్‌ను ప్రారంభించండి అభ్యంతరకరమైన కంటెంట్‌ను దాచు బ్యాకప్ మరియు పునరుద్ధరణ బ్యాకప్ పునరుద్ధరణ ప్లేజాబితా దిగుమతి చేయబడింది బ్యాకప్ విజయవంతంగా సృష్టించబడింది బ్యాకప్‌ను సృష్టించలేకపోయింది బ్యాకప్‌ను పునరుద్ధరించడంలో విఫలమైంది డిస్కార్డ్ ఇంటిగ్రేషన్ ఇన్నర్‌ట్యూన్ మీ డిస్కార్డ్ తా స్థితిని సెట్ చేసేందుకు కిజ్జీఆర్‌పిసి లైబ్రరీని ఉపయోగిస్తుంది. ఇది డిస్కార్డ్ Gateway కనెక్షన్ ఉపయోగించడం ద్వారా జరుగుతుంది, ఇది Discord యొక్క TOSను ఉల్లంఘించడం గా పరిగణించబడవచ్చు. అయితే, ఈ కారణం వల్ల వినియోగదారుల ఖాతాలు సస్పెండ్ అయిన సందర్భాలు ఎవ్వా తెలిసినవి కాదు. మీ ధైర్యంతో ఉపయోగించండి.\n\nఇన్నర్‌ట్యూన్ మీ టోకెన్ ను మాత్రమే తీసుకుందని, మిగతా అన్ని విషయాలు స్థానికంగా నిల్వ చేయబడ్డాయి. తొలగించు పరిదృశ్యం రిచ్ ప్రెజెన్స్‌ను ఎనేబుల్ చేయండి యాప్ వెర్షన్ కొత్త వెర్షన్ అందుబాటులో ఉంది అనువాద మోడల్స్ అనువాద నమూనాలను క్లియర్ చెయ్యండి ================================================ FILE: app/src/main/res/values-th/metrolist_strings.xml ================================================ ในเครื่อง จากบัญชี ชาร์ตเพลง ย้อนกลับ ปกอัลบั้ม มิวสิกวิดีโอยอดนิยม กำลังมาแรง สัปดาห์ เดือน ปี ต่อเนื่อง ถูกใจ ดาวน์โหลดแล้ว เพลงที่คุณฟังบ่อยที่สุด แคชแล้ว อัปโหลดแล้ว อัปโหลดแล้ว ซิงค์เพลย์ลิสต์ ปิดการซิงค์แล้ว หมายเหตุ: การเปิดใช้งานนี้จะทำให้สามารถซิงค์กับ YouTube Music ได้ และไม่สามารถเปลี่ยนแปลงภายหลังได้ กำลังสร้างรูปภาพ… โปรดรอสักครู่ ยกเลิก เปิดใช้งาน แชร์เนื้อเพลง แชร์เป็นข้อความ แชร์เป็นรูปภาพ แชร์ที่เลือก ปรับแต่งสี สีข้อความ สีข้อความรอง สีพื้นหลัง ลบออกจากแคช เกี่ยวกับศิลปิน แสดงเพิ่มเติม แสดงน้อยลง แสดงคำอธิบายศิลปิน แสดงจำนวนผู้ติดตาม จำนวนที่เลือกได้สูงสุด หน้าศิลปิน แสดงจำนวนผู้ฟังรายเดือน ดาวน์โหลดเพลงทั้งหมดไว้ฟังแบบออฟไลน์ ลบเพลงที่ดาวน์โหลดทั้งหมดออกจากเพลย์ลิสต์นี้ กำลังดาวน์โหลด แชร์เพลย์ลิสต์นี้ให้ผู้อื่น ลบเพลย์ลิสต์นี้อย่างถาวร ซิงค์เพลย์ลิสต์กับ YouTube Music คัดลอกลิงก์ เลือกทั้งหมด ถูกใจทั้งหมด ไม่ถูกใจทั้งหมด คัดลอกลิงก์แล้ว วันที่อัปเดตล่าสุด กำลังเริ่มวิทยุ กำลังเล่น เนื้อเพลง ปิด ซ่อนภาพตัวอย่างในเครื่องเล่น แทนที่ภาพปกอัลบั้มด้วยโลโก้แอปในเครื่องเล่น ครอบตัดภาพปกอัลบั้ม บังคับให้เป็นอัตราส่วนสี่เหลี่ยมจัตุรัสโดยครอบตัดภาพตัวอย่างวิดีโอ มีอยู่ในเพลย์ลิสต์แล้ว: %d ครั้ง +%1$d วินาที -%1$d วินาที หากเปิดใช้งาน จะเพิ่มเวลาอีก 5 วินาทีแบบสะสมทุกครั้งที่กดข้าม เพิ่มเวลาข้ามแบบต่อเนื่อง เนื้อหาที่คล้ายกัน รูปแบบพื้นหลังเครื่องเล่น ตามธีมระบบ ไล่ระดับสี ดีไซน์เครื่องเล่นใหม่ ดีไซน์มินิเพลเยอร์ใหม่ เบลอพื้นหลัง สีปุ่มเครื่องเล่น ค่าเริ่มต้น สีหลัก สีระดับที่สาม คลื่น เปิดใช้งานการปัดเพื่อเปลี่ยนเพลง ปัดเพลงไปทางซ้ายเพื่อเพิ่มในคิว หรือปัดไปทางขวาเพื่อเล่นเป็นลำดับถัดไป ปัดเพลงเพื่อเอาออกจากเพลย์ลิสต์ เปลี่ยนเนื้อเพลงเมื่อแตะ เลื่อนเนื้อเพลงอัตโนมัติ เปิดเอฟเฟกต์เรืองแสงของเนื้อเพลง เพิ่มแอนิเมชันเรืองแสงและเอฟเฟกต์เด้งให้กับเนื้อเพลงที่กำลังแสดง เปิดใช้งาน Better Lyrics เนื้อเพลงแบบซิงค์ระดับพยางค์สำหรับทุกเพลง เหมาะสำหรับร้องคาราโอเกะ เปิดใช้งาน SimpMusic Lyrics ดึงเนื้อเพลงอัตโนมัติจาก Musixmatch และ YouTube Transcript ซิงค์ใหม่ บาง แถบนำทางด้านล่างแบบบาง เพลย์ลิสต์อัตโนมัติ แสดงเพลย์ลิสต์ \"ถูกใจ\" แสดงเพลย์ลิสต์ \"ดาวน์โหลดแล้ว\" แสดงเพลย์ลิสต์ \"แคช\" แสดงเพลย์ลิสต์ \"อัปโหลดแล้ว\" เล่นแบบสุ่มจากเพลย์ลิสต์/อัลบั้มก่อน เมื่อเปิดโหมดสุ่ม จะเล่นเพลงทั้งหมดจากเพลย์ลิสต์/อัลบั้มเดิมก่อน แล้วจึงเล่นเนื้อหาที่คล้ายกัน แสดงการ์ด Wrapped ข้ามช่วงที่เงียบของเพลงอย่างรวดเร็ว ข้ามช่วงเงียบทันที กระโดดข้ามช่วงเงียบแทนการเร่งความเร็วการเล่น แสดงเพลย์ลิสต์ \"เพลงที่คุณฟังบ่อยที่สุด\" เข้าสู่ระบบด้วยโทเค็น แตะเพื่อแสดงโทเค็น แตะอีกครั้งเพื่อคัดลอกหรือแก้ไข นี่เป็นวิธีเข้าสู่ระบบขั้นสูง คุณสามารถกรอกหรืออัปเดตโทเค็นเข้าสู่ระบบได้โดยตรงแทนการใช้เว็บพอร์ทัล วิธีนี้ช่วยให้เข้าสู่ระบบบนหลายอุปกรณ์ได้รวดเร็วขึ้น โปรดทราบว่าโทเค็นที่มีรูปแบบไม่ถูกต้องจะไม่สามารถใช้งานได้ ซิงค์กับบัญชีโดยอัตโนมัติ เนื้อหาเพิ่มเติม แก้ไขปกเพลย์ลิสต์ หมายเหตุ: บัญชีของคุณต้องเชื่อมโยงกับหมายเลขโทรศัพท์และยืนยันตัวตนบน YouTube Music ก่อนจึงจะสามารถเปลี่ยนปกเพลย์ลิสต์ได้ หลังจากเลือกภาพแล้ว โปรดรอสักครู่เพื่อให้ปกใหม่แสดงในเพลย์ลิสต์ของคุณ เลือกจากคลัง ลบภาพที่กำหนดเอง ทั่วไป พร็อกซี เปลี่ยนแท็บเริ่มต้นในคลัง อิงจากเพลงล่าสุดที่ฟัง ภาษาของแอป ตั้งค่าการเลือกด่วน ตั้งค่าพร็อกซี ชื่อผู้ใช้พร็อกซี รหัสผ่านพร็อกซี เปิดใช้งานการยืนยันตัวตน ใช้รายละเอียดแทนสถานะ แสดงชื่อเพลงอย่างเด่นชัดแทนชื่อศิลปิน เปิดใช้งานเนื้อหาที่คล้ายกัน เพิ่มเพลงที่คล้ายกันโดยอัตโนมัติเมื่อถึงท้ายคิว โหมดสุ่มคงที่ คงสถานะโหมดสุ่มไว้เมื่อเริ่มเพลงหรือเพลย์ลิสต์ใหม่ จดจำการตั้งค่าโหมดสุ่มและเล่นซ้ำ จดจำโหมดสุ่มและเล่นซ้ำเมื่อเปิดแอปใหม่ %d%% นำเข้าเพลย์ลิสต์ \"m3u\" นำเข้าเพลย์ลิสต์ \"csv\" หมายเหตุ: ไม่รองรับการเพิ่มเพลงในเครื่องลงในเพลย์ลิสต์ที่ซิงค์/ระยะไกล การเพิ่มในรูปแบบอื่นสามารถใช้งานได้ตามปกติ ดาวน์โหลดอัตโนมัติเมื่อกดถูกใจ ดาวน์โหลดเพลงโดยอัตโนมัติเมื่อคุณกดถูกใจ ความไวในการปัดของมินิเพลเยอร์ %1$d%% คุณแน่ใจหรือไม่ว่าต้องการลบแคชเพลงทั้งหมด? คุณแน่ใจหรือไม่ว่าต้องการลบแคชรูปภาพทั้งหมด? คุณแน่ใจหรือไม่ว่าต้องการลบไฟล์ที่ดาวน์โหลดทั้งหมด? ปิดใช้งาน ยังไม่ได้เข้าสู่ระบบ YouTube เปิดลิงก์ที่รองรับ ไม่สามารถเปิดการตั้งค่าแอปได้ บันทึกการอัปเดต ตลอดเวลา 24 ชั่วโมงที่ผ่านมา สัปดาห์ที่ผ่านมา เดือนที่ผ่านมา ปีที่ผ่านมา จำนวนรายการใน \"เพลงที่คุณฟังบ่อยที่สุด\" ระยะเวลาการเก็บประวัติ ข้อมูล คำอธิบาย ยอดเข้าชม จำนวนถูกใจ จำนวนไม่ถูกใจ ติดตาม ติดตามแล้ว %d วินาที ปิดการโหลดเพิ่มเมื่อเปิดโหมดเล่นซ้ำทั้งหมด ไม่โหลดเพลงและเนื้อหาที่คล้ายกันเพิ่มโดยอัตโนมัติเมื่อเปิดโหมดเล่นซ้ำทั้งหมด หยุดเพลงเมื่อปิดเสียงสื่อ เปิดหน้าจอไว้เมื่อขยายเครื่องเล่น ครอสเฟด ครอสเฟดระหว่างเพลง ระยะเวลาครอสเฟด ปิดสำหรับอัลบั้มแบบไม่มีช่วงว่าง ไม่ใช้ครอสเฟดหากอัลบั้มไม่มีช่วงว่างระหว่างเพลง ฟีเจอร์เบต้า ครอสเฟดเป็นฟีเจอร์ใหม่และอาจมีข้อผิดพลาด หากพบปัญหา โปรดรายงานให้เราทราบ\n\nฟีเจอร์นี้จะปิดการใช้งาน audio offload เนื่องจากข้อจำกัดทางเทคนิค ซีริลลิก การถอดเสียง การถอดเสียงเนื้อเพลง ถอดเสียงเนื้อเพลงภาษาญี่ปุ่น ถอดเสียงเนื้อเพลงภาษาเกาหลี ถอดเสียงเนื้อเพลงภาษาจีน ถอดเสียงเนื้อเพลงภาษารัสเซีย ถอดเสียงเนื้อเพลงภาษายูเครน ถอดเสียงเนื้อเพลงภาษาเบลารุส ถอดเสียงเนื้อเพลงภาษาคีร์กีซ ถอดเสียงเนื้อเพลงภาษาเซอร์เบีย ถอดเสียงเนื้อเพลงภาษาบัลแกเรีย ทดลองใช้: ตรวจจับภาษาทีละบรรทัด ตรวจจับภาษากลุ่มซีริลลิกแบบทีละบรรทัดแทนการตรวจทั้งเพลง คุณแน่ใจหรือไม่? ฟีเจอร์นี้ยังอยู่ในขั้นทดลองและอาจทำงานได้ไม่สม่ำเสมอ\n\nโดยปกติ ระบบจะตรวจจับภาษาจากทั้งเพลง แต่เมื่อเปิดตัวเลือกนี้ จะตรวจจับทีละบรรทัดแทน ซึ่งช่วยให้เพลงหลายภาษาใช้งานได้ดีขึ้น อย่างไรก็ตาม ภาษาอาจตรวจจับไม่ถูกต้องเสมอไป (เช่น หากมีเนื้อเพลงภาษายูเครนที่ไม่มีตัวอักษรเฉพาะ ระบบอาจถอดเสียงเป็นภาษารัสเซียแทน)\n\nหากคุณไม่พบปัญหา แนะนำให้ปิดตัวเลือกนี้ไว้ ถอดเสียงเพลงที่กำลังเล่น การหน่วงเวลาเนื้อเพลง อินเทอร์เฟซ ความเป็นส่วนตัวและความปลอดภัย เครื่องเล่นและเนื้อหา พื้นที่จัดเก็บและข้อมูล ระบบและเกี่ยวกับแอป ตัวอัปเดต ตรวจสอบการอัปเดตอัตโนมัติ เปิดการแจ้งเตือนการอัปเดต มีการอัปเดตใหม่ การอัปเดตแอป การแจ้งเตือนเกี่ยวกับเวอร์ชันใหม่ เปิดใช้งาน Audio Offload ใช้เส้นทางเสียงแบบ offload สำหรับการเล่นเสียง การปิดใช้งานอาจทำให้ใช้พลังงานเพิ่มขึ้น แต่สามารถช่วยแก้ปัญหาการเล่นเสียงหรือการประมวลผลเสียงเพิ่มเติมได้ ปิดใช้งานเนื่องจากเปิดครอสเฟดอยู่ Google Cast เปิดใช้งานการส่งเสียงไปยัง Chromecast และอุปกรณ์ที่รองรับ Cast อื่น ๆ ถอดเสียงเนื้อเพลงภาษามาซิโดเนีย การเชื่อมต่อบริการ ชื่อผู้ใช้ รหัสผ่าน การเชื่อมต่อ Last.fm เปิดใช้งาน Scrobbling ส่งสถานะ “กำลังเล่น” ส่งสถานะถูกใจ/ยกเลิกถูกใจ กด Love/Unlove เพลงใน Last.fm เมื่อมีการกดถูกใจ/ยกเลิกถูกใจใน Metrolist กำลังเข้าสู่ระบบ… การตั้งค่า Scrobbling Scrobble เพลงที่ยาวกว่า เปอร์เซ็นต์หน่วงเวลาก่อน Scrobble หน่วงเวลาก่อน Scrobble (นาที) ซ่อนเพลงที่เป็นวิดีโอ ซ่อน YouTube Shorts ดูข้อมูลเพลง เปลี่ยนชื่อเพลงหรือศิลปิน สร้างสถานีจากรายการนี้ เพิ่มไว้ลำดับถัดไป เพิ่มไว้ท้ายคิว บันทึกไปยังคลังของคุณ ดาวน์โหลดเพื่อฟังแบบออฟไลน์ เพิ่มไปยังเพลย์ลิสต์ของคุณ ดึงข้อมูลล่าสุดจาก YouTube Music แชร์ลิงก์ของรายการนี้ ลบรายการนี้อย่างถาวร ปรับความเร็วและคีย์เสียงของเพลง ปรับอีควอไลเซอร์เสียง เปิดใช้งานไอคอนไดนามิก มินิเพลเยอร์ มินิเพลเยอร์สีดำสนิท เดี๋ยวก่อน! คุณได้เลือกขีดจำกัดแคชที่น้อยกว่าที่แอปกำลังใช้อยู่ในขณะนี้ (%1$s) หากดำเนินการต่อ แอปอาจลบ %2$s บางส่วนที่ถูกแคชไว้เพื่อให้ตรงกับขีดจำกัดใหม่ ต้องการดำเนินการต่อหรือไม่? ดำเนินการต่อ รูปแบบแอนิเมชันแบบคำต่อคำ ไม่มี จางเข้า เรืองแสง เลื่อนเข้า คาราโอเกะ สไตล์ Apple Music ขนาดตัวอักษรเนื้อเพลง ระยะห่างบรรทัดเนื้อเพลง ปกอัลบั้มของ %s คุณฟังไปทั้งหมด อัลบั้มที่ไม่ซ้ำกัน อัลบั้มที่คุณฟังมากที่สุดคือ เพลย์ลิสต์ส่วนตัวของคุณพร้อมแล้ว 5 อัลบั้มที่คุณฟังมากที่สุด คุณฟังอัลบั้มนี้ไปแล้ว %d นาที %d นาที ไม่มีข้อมูล ศิลปินที่คุณฟังมากที่สุดในปีนี้ %d นาที เพลงที่คุณฟังมากที่สุดในปีนี้ ปกอัลบั้ม ศิลปินที่คุณฟังมากที่สุดในปีนี้คือ รูปภาพศิลปินอันดับหนึ่ง คุณฟังศิลปินคนนี้ไปแล้ว %d นาที เพลงที่คุณเปิดฟังมากที่สุดคือ คุณฟังเพลงนี้ไปแล้ว %d นาที คุณฟังไปทั้งหมด ศิลปินที่ไม่ซ้ำกัน คุณฟังไปทั้งหมด เพลงที่ไม่ซ้ำกัน METROLIST ได้เวลามาดูสิ่งที่คุณฟังไปตลอดปี ไปดูกันเลย! โลโก้ Metrolist 2025 WRAPPED ของคุณพร้อมแล้ว! มาดูสิ่งที่คุณชื่นชอบในปีนี้กัน ขอบคุณที่รับฟัง ขอขอบคุณเป็นพิเศษแก่ MO Agamy ผู้สร้าง Metrolist ปิด Wrapped Wrapped %s ของคุณ สร้างเพลย์ลิสต์ บันทึกเพลย์ลิสต์แล้ว %d โปรไฟล์ อีควอไลเซอร์ ไม่มีโปรไฟล์อีควอไลเซอร์ นำเข้าโปรไฟล์ อีควอไลเซอร์ของระบบ ปิดใช้งาน %d แบนด์ ลบโปรไฟล์ คุณแน่ใจหรือไม่ว่าต้องการลบ %1$s? การดำเนินการนี้ไม่สามารถย้อนกลับได้ ไม่สามารถอ่านไฟล์ได้ ไม่สามารถเปิดไฟล์: %1$s ข้อผิดพลาดในการนำเข้า ข้อผิดพลาด ไม่สามารถใช้โปรไฟล์ EQ : %1$s กำลังแคสต์ไปยัง %s ความคืบหน้า %s%% กำลังฟังผ่าน Metrolist เปิด ไม่สามารถสร้างรูปภาพ: %s คัดลอกชื่อเพลงแล้ว คัดลอกชื่อศิลปินแล้ว เกิดข้อผิดพลาดในการเล่น ไม่สามารถอ่านค่า Proxy URL ได้ การเล่นล้มเหลว ปกอัลบั้ม ไม่มีเพลงกำลังเล่น แตะเพื่อเปิด Metrolist ก่อนหน้า เล่น/หยุด ถัดไป ถูกใจ ไม่มีเพลงกำลังเล่น แตะเพื่อเปิด Metrolist วิดเจ็ตเครื่องเล่นเพลงพร้อมปุ่มควบคุมการเล่น วิดเจ็ตเพลงแบบวงกลม พร้อมปุ่มเล่นและถูกใจ เครื่องเล่นเพลง เทิร์นเทเบิล ร่วมกัน ฟังเพลงร่วมกัน URL เซิร์ฟเวอร์ เลือกเซิร์ฟเวอร์ เซิร์ฟเวอร์กำหนดเอง ใช้เซิร์ฟเวอร์กำหนดเอง ชื่อผู้ใช้ เชื่อมต่อแล้ว กำลังเชื่อมต่อใหม่… ตัดการเชื่อมต่อแล้ว กำลังเชื่อมต่อ… เกิดข้อผิดพลาดในการเชื่อมต่อ สร้างห้อง สร้างห้องและแชร์รหัสให้เพื่อน เข้าร่วมห้อง รหัสห้อง คุณคือโฮสต์ คุณคือผู้เข้าร่วม ปิดเสียง เปิดเสียง คำขอเข้าร่วม ดูบันทึกการทำงาน ตรวจสอบการเชื่อมต่อและข้อความเพื่อแก้ไขปัญหา บันทึกการเชื่อมต่อ ยังไม่มีบันทึกการทำงาน อนุมัติคำขอเข้าร่วมอัตโนมัติ อนุมัติคำขอเข้าร่วมโดยอัตโนมัติ แทนการตรวจสอบด้วยตนเอง ซิงก์ระดับเสียงกับโฮสต์ ผู้เข้าร่วมจะใช้ระดับเสียงเดียวกับโฮสต์ แสดง Listen Together บนแถบด้านบน แสดง Listen Together บนแถบด้านบนของแอป แทนแถบนำทางด้านล่าง ฟังเพลงกับเพื่อนแบบเรียลไทม์ สร้างห้องเพื่อเป็นโฮสต์ หรือเข้าร่วมห้องที่มีอยู่แล้วด้วยรหัส หมายเหตุ: คุณอาจถูกตัดการเชื่อมต่อ หากสร้างห้องในขณะที่ไม่มีเพลงเล่นอยู่ แล้วสลับไปใช้แอปอื่น ยังไม่ได้ตั้งค่า Listen Together โปรดกำหนด URL เซิร์ฟเวอร์ใน การตั้งค่า → การเชื่อมต่อบริการ → Listen Together %1$s ขอให้เล่น %2$s ส่งคำแนะนำไปยังโฮสต์แล้ว! %1$s ต้องการเข้าร่วมห้อง Listen Together การแจ้งเตือนเกี่ยวกับกิจกรรมของ Listen Together สร้างห้อง %s แล้ว ไม่สามารถแก้ไขชื่อผู้ใช้ขณะอยู่ในห้องได้ กำลังรอการอนุมัติจากโฮสต์ รหัสห้องไม่ถูกต้อง คำขอเข้าร่วมถูกปฏิเสธ เข้าร่วมห้องที่มีอยู่ รหัสห้อง ออกจากห้อง เข้าร่วม สร้าง กำลังเข้าร่วมห้อง %s… กำลังสร้างห้อง… เชื่อมต่อ ตัดการเชื่อมต่อ สร้าง เข้าร่วม อนุมัติ ปฏิเสธ ล้าง คัดลอก คัดลอกไปยังคลิปบอร์ดแล้ว ยังไม่ได้ตั้งค่า กำลังเป็นโฮสต์ อยู่ในห้อง คำขอที่รอดำเนินการ คำแนะนำที่รอดำเนินการ แนะนำให้โฮสต์ นำออก โฮสต์ คุณ ผู้ใช้ที่เชื่อมต่ออยู่ กรอกชื่อผู้ใช้ กรอกรหัสห้อง ตั้งค่าเซิร์ฟเวอร์ ชื่อผู้ใช้ และอื่น ๆ จำเป็นต้องระบุชื่อผู้ใช้ ซิงก์ใหม่ คัดลอกรหัส นำบุคคลนี้ออกจากเซสชัน บล็อกถาวร บล็อกคำขอเข้าร่วมของบุคคลนี้ และซ่อนคำแนะนำของเขา โอนสิทธิ์ความเป็นเจ้าของ ให้บุคคลนี้เป็นโฮสต์ของห้อง จัดการผู้ใช้ ผู้ใช้ที่ถูกบล็อก บล็อกผู้ใช้แล้ว %d คน ไม่มีผู้ใช้ที่ถูกบล็อก เลิกบล็อก ผู้ใช้ถูกบล็อกโดยโฮสต์ แปลเนื้อเพลงด้วย AI กำลังแปลเนื้อเพลง... แปลเนื้อเพลงแล้ว ผู้ให้บริการ Base URL API Key Model โหมดการแปล ภาษาปลายทาง API Credentials การแปลภาษา การถอดเสียง จำเป็นต้องใช้ API Key จำเป็นต้องระบุ API Key ไม่มีเนื้อเพลงให้แปล เนื้อเพลงว่างเปล่า จำเป็นต้องระบุภาษาปลายทาง ผลลัพธ์การแปลไม่ถูกต้องตามที่คาดไว้ เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ การแปลล้มเหลว แอปหยุดทำงาน เกิดข้อผิดพลาดที่ไม่คาดคิด โปรดแชร์รายงานข้อขัดข้องเพื่อช่วยให้เราแก้ไขปัญหา แชร์บันทึกข้อขัดข้อง แชร์รายงานข้อขัดข้อง รายงานข้อขัดข้องของ Metrolist ปิด ไม่มีบันทึกข้อขัดข้อง ไดนามิก คริมสัน โรส ม่วง ม่วงเข้ม อินดิโก น้ำเงิน ฟ้าท้องฟ้า ไซแอน ทีล เขียว เขียวอ่อน ไลม์ เหลือง แอมเบอร์ ส้ม ส้มเข้ม น้ำตาล เทา เทาน้ำเงิน ย้อนกลับ โหมดดำสนิท โหมดสว่าง โหมดมืด ตามค่าระบบ ชุดสี %1$s เล่นทั้งหมด เปิดใช้งานรีเฟรชเรตสูง บังคับให้หน้าจอทำงานที่อัตรารีเฟรชสูงสุดที่รองรับ (เช่น 120Hz) ค้นหาเพลงจากเสียง แตะเพื่อค้นหาเพลง กำลังฟัง… กำลังประมวลผล… ไม่พบเพลงที่ตรงกัน เกิดข้อผิดพลาดในการรู้จำเพลง ลองอีกครั้ง ประวัติการค้นหาเพลง ล้างประวัติการค้นหาเพลง คุณแน่ใจหรือไม่ว่าต้องการล้างประวัติการค้นหาเพลงทั้งหมด? ออก ฟังอีกครั้ง เล่นบน Metrolist จับคู่คอลัมน์ CSV แถวแรกเป็นหัวตาราง คอลัมน์ชื่อศิลปิน คอลัมน์ชื่อเพลง คอลัมน์ YouTube URL (ไม่บังคับ) ดำเนินการต่อ กำลังนำเข้าไฟล์ CSV แปลงล่าสุด คอลัมน์ %d ป้องกันแทร็กซ้ำในคิว เมื่อเพิ่มแทร็กลงในคิว หากมีอยู่แล้วให้ลบออกจากตำแหน่งเดิม แปลความหมายเป็นภาษาปลายทาง แปลงเสียงอ่านเป็นอักษรของภาษาปลายทาง รับ API Key ไปที่ https://openrouter.ai เพื่อดูโมเดลแบบฟรีและแบบชำระเงิน ไปที่ https://platform.openai.com/api-keys ไปที่ https://console.anthropic.com/settings/keys ไปที่ https://aistudio.google.com/apikey ไปที่ https://perplexity.ai/settings/api ไปที่ https://console.x.ai ไปที่ https://deepl.com/pro-api เพื่อรับ API Key แบบฟรีหรือแบบชำระเงิน ระดับความเป็นทางการ ค่าเริ่มต้น ทางการมากขึ้น ทางการน้อยลง สถานะ ออนไลน์ ห้ามรบกวน ปุ่ม ปุ่มที่ 1 ปุ่มที่ 2 เข้าสู่ระบบสำเร็จ! ฟีเจอร์นี้ใช้ไลบรารี KizzyRPC เพื่อเชื่อมต่อกับ Discord Gateway และตั้งค่า Rich Presence ของคุณ แม้ยังไม่มีรายงานว่าบัญชีถูกระงับจากการใช้งานลักษณะนี้ แต่วิธีนี้ไม่ได้รับการสนับสนุนอย่างเป็นทางการจาก Discord และอาจเข้าข่ายละเมิดข้อกำหนดการให้บริการ โทเคนของคุณจะถูกดึงและใช้งานภายในเครื่องเท่านั้น และจะไม่ถูกส่งไปยังเซิร์ฟเวอร์ของบุคคลที่สาม โปรดพิจารณาและตัดสินใจใช้งานด้วยความระมัดระวัง ไม่อยู่ ประเภทกิจกรรม กำลังเล่น กำลังฟัง กำลังรับชม กำลังแข่งขัน ตัวแปร: {song_name}, {artist_name}, {album_name} ตัวอย่าง Rich Presence สถานะการแสดงตัว เข้าสู่ระบบด้วย Discord เพื่อแชร์สิ่งที่คุณกำลังฟัง กำลังเล่น Metrolist กำลังรับชม Metrolist กำลังแข่งขันใน Metrolist ชื่อกิจกรรม กำหนดชื่อกิจกรรมเอง (เว้นว่างเพื่อใช้ค่าเริ่มต้น) โหมดขั้นสูง แสดงตัวเลือกการปรับแต่งเพิ่มเติมสำหรับ Rich Presence สีทึบ เล่นต่อเมื่อเชื่อมต่อบลูทูธ ถอดเสียงเนื้อเพลงภาษาฮินดี ถอดเสียงเนื้อเพลงภาษาปัญจาบ แสดงเนื้อเพลงที่ถอดเสียงเป็นหลัก ความหนาแน่นการแสดงผล เริ่มต้นใหม่ ต้องรีสตาร์ท การเปลี่ยนความหนาแน่นของหน้าจอจะมีผลหลังจากรีสตาร์ทแอป คุณต้องการรีสตาร์ทตอนนี้หรือไม่? ฐานข้อมูลเนื้อเพลงแบบซิงค์ที่ขับเคลื่อนโดยชุมชนผู้ใช้ ดึงเนื้อเพลงจาก KuGou แพลตฟอร์มเพลงยอดนิยมของจีน หมายเหตุ: เนื้อเพลงจาก YouTube Music จะแสดงโดยอัตโนมัติเมื่อไม่พบเนื้อเพลงจากแหล่งอื่น โดยทั่วไปเนื้อเพลงจาก YTM มักจะไม่ซิงค์กับเพลง เปิดใช้งาน LyricsPlus เนื้อเพลงแบบซิงค์จากหลายแหล่ง การเลือกผู้ให้บริการเนื้อเพลง เลือกแหล่งที่มาของเนื้อเพลงที่ต้องการเปิดใช้งาน ลำดับความสำคัญของแหล่งเนื้อเพลง ลากเพื่อจัดเรียงลำดับผู้ให้บริการตามความต้องการ ตำแหน่งที่อยู่สูงกว่าจะมีลำดับความสำคัญมากกว่า บันทึกการเปลี่ยนแปลง ยังไม่มีบันทึกการเปลี่ยนแปลง https://github.com/MetrolistGroup/Metrolist/releases ดูบน GitHub เวอร์ชันปัจจุบัน เวอร์ชัน: %s การตั้งค่าการอัปเดต ตรวจสอบการอัปเดต กำลังตรวจสอบการอัปเดต… เวอร์ชันล่าสุด: %s ตรวจสอบการอัปเดต ซ่อนบันทึกการเปลี่ยนแปลง ดูบันทึกการเปลี่ยนแปลง ไม่สามารถตรวจสอบการอัปเดต: %s ตั้งเป็นค่าเริ่มต้น ตั้งค่าเวลาปิดเพลงอัตโนมัติเริ่มต้นเป็น %d นาที ดูได้ที่ การตั้งค่า > เนื้อหา ครั้ง เกิดข้อผิดพลาดในการบันทึกตอน เกิดข้อผิดพลาดในการลบตอน เกิดข้อผิดพลาดในการติดตามพอดแคสต์ เกิดข้อผิดพลาดในการเลิกติดตามพอดแคสต์ อนุมัติคำแนะนำเพลงอัตโนมัติ อนุมัติและเพิ่มเพลงที่ผู้เข้าร่วมแนะนำเข้าสู่คิวโดยอัตโนมัติ กำลังนำเข้าเพลย์ลิสต์ ปุ่มลัด ปักหมุดไว้ในปุ่มลัด เอาหมุดออกจากปุ่มลัด สุ่มลำดับหน้าแรก สุ่มจัดเรียงส่วนต่าง ๆ ในหน้าแรกใหม่ตามลำดับความสำคัญแบบถ่วงน้ำหนัก ฟังดูคล้ายกับ %1$s เพราะคุณฟัง %1$s คล้ายกับ %1$s อิงจาก %1$s สำหรับแฟน ๆ ของ %1$s จากชุมชนผู้ใช้ เก็บข้อมูลคลังเพลงไว้หรือไม่? คุณต้องการเก็บเพลย์ลิสต์และข้อมูลคลังเพลงไว้หรือไม่? เพลงที่ดาวน์โหลดไว้จะยังคงอยู่ไม่ว่าคุณจะเลือกแบบใด เก็บไว้ ล้างข้อมูล หัวหน้านักพัฒนา ผู้ร่วมพัฒนา ผู้ร่วมพัฒนา GNU General Public License v3.0 ซอฟต์แวร์โอเพนซอร์สฟรี คุณสามารถใช้งาน ศึกษา แจกจ่าย และพัฒนาเพิ่มเติมได้ เซิร์ฟเวอร์ Discord ช่อง Telegram เว็บไซต์ Instagram GitHub ดูที่เก็บโค้ด %1$s • %2$s ชอบผลงานของฉันไหม? เลี้ยงกาแฟฉันสักแก้ว ชุมชนและข้อมูล METROLIST อยากเปิดเพลงโปรดของพวกเขาไหม? ใช่เลย โปรเจกต์นี้ขอยืนหยัดเคียงข้างปาเลสไตน์ 🇵🇸 พอดแคสต์ ดูพอดแคสต์ ช่องพอดแคสต์ ตอนล่าสุด รายการของคุณ ตอนใหม่ ตอนที่บันทึกไว้ฟังทีหลัง บันทึกไว้ฟังทีหลัง เพิ่มลงในเพลย์ลิสต์ “ฟังทีหลัง” นำออกจากรายการที่บันทึกไว้ บันทึกพอดแคสต์ไว้ในคลัง %d ตอน กู้คืนข้อมูลจากแบ็กอัป? การดำเนินการนี้จะกู้คืนข้อมูลแอปจากไฟล์แบ็กอัป หลังการกู้คืน คุณจะต้องเข้าสู่ระบบอีกครั้ง บัญชีต่อไปนี้จะถูกออกจากระบบ: กู้คืน กำลังตรวจสอบบัญชีเดิม… ไม่พบบัญชี ระบบค้นหาเพลง ระบุชื่อเพลงที่กำลังเล่นรอบตัวคุณได้จากหน้าจอหลักโดยตรง แตะเพื่อค้นหาเพลง กำลังฟัง… กำลังระบุเพลง… ไม่พบเพลงที่ตรงกัน ลองใหม่อีกครั้ง การค้นหาเพลงล้มเหลว เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง ไม่ทราบชื่อเพลง ไม่ทราบชื่อศิลปิน ค้นหาเพลง การค้นหาเพลง แสดงการแจ้งเตือนขณะกำลังค้นหาเพลงจากวิดเจ็ต กำลังบันทึกเสียงเพื่อระบุเพลง… ตอน ช่อง เพลย์ลิสต์อัตโนมัติ ตอนที่ดาวน์โหลดแล้ว ยังไม่มีช่องที่ติดตาม ยังไม่มีตอนที่ดาวน์โหลด %d ช่อง ดูช่อง โปรไฟล์ เปิดตัวตั้งเวลาปิดอัตโนมัติ เปิดใช้งานตัวตั้งเวลาปิดอัตโนมัติด้วยค่าเริ่มต้นตามเวลาที่กำหนดเอง กำหนดวันและเวลาที่ต้องการให้ตัวตั้งเวลาปิดทำงานอัตโนมัติ ทำซ้ำ ทุกวัน วันจันทร์–ศุกร์ วันธรรมดา / วันหยุดสุดสัปดาห์ วันหยุดสุดสัปดาห์ (เสาร์–อาทิตย์) กำหนดเอง เวลาเริ่มต้น เวลาสิ้นสุด วันจันทร์ วันอังคาร วันพุธ วันพฤหัสบดี วันศุกร์ วันเสาร์ วันอาทิตย์ หยุดเล่นเมื่อเพลงปัจจุบันจบเมื่อครบเวลาที่ตั้งไว้ ค่อย ๆ ลดเสียงลงในนาทีสุดท้าย อัปโหลดเพลง กำลังอัปโหลด… %1$d จาก %2$d อัปโหลดเสร็จสิ้น อัปโหลดไม่สำเร็จ ไฟล์มีขนาดใหญ่เกินไป (สูงสุด 300MB) รูปแบบไฟล์ไม่รองรับ กรุณาใช้ mp3, m4a, wma, flac หรือ ogg ลบเพลงที่อัปโหลด คุณแน่ใจหรือไม่ว่าต้องการลบเพลงที่อัปโหลดนี้? การดำเนินการนี้ไม่สามารถย้อนกลับได้ ลบเพลงที่อัปโหลดแล้ว ไม่สามารถลบเพลงที่อัปโหลดได้ ลบเพลงที่อัปโหลด คุณแน่ใจหรือไม่ว่าต้องการลบเพลงที่อัปโหลด %1$d เพลง? การดำเนินการนี้ไม่สามารถย้อนกลับได้ ลบแล้ว %1$d เพลง กำลังลบ… ================================================ FILE: app/src/main/res/values-tr/metrolist_strings.xml ================================================ Yerel Uzak Listeler Geri Albüm açıklaması Popüler Müzik Videoları Trendler Haftalar Aylar Yıllar Sürekli Beğenilen İndirilen En İyilerim Önbelleğe Alınan Oynatma listesini senkronize et Senkronizasyon devre dışı Not: Bu, YouTube Music ile senkronizasyona izin verir. Sonradan DEĞİŞTİRİLEMEZ. Önbellekten kaldır Bağlantıyı kopyala Tümünü seç Tümünü beğen Tümünü beğenme Güncelleme tarihi Bağlantı panoya kopyalandı Şarkı sözleri Zaten oynatma listesinde: 1 kez %d kez Benzer içerik Oynatıcı arka plan stili Temayı takip et Gradyan Bulanıklık Oynatıcı düğme renkleri Varsayılan Şarkıyı değiştirmek için kaydırmayı etkinleştir Şarkıyı kuyruğa eklemek için sola, sonra çalmak için sağa kaydırın Tıklama ile şarkı sözlerini değiştir İnce İnce alt gezinme çubuğu Otomatik Oynatma Listeleri \"Beğenilen\" Oynatma Listesini Göster \"İndirilen\" Oynatma Listesini Göster \"En İyi\" Oynatma Listesini Göster \"Önbelleğe Alınan\" oynatma listesini göster Token ile giriş yap Tokeni göstermek için dokunun Kopyalamak veya düzenlemek için tekrar dokunun Bu, GELİŞMİŞ bir giriş yöntemidir. Web portalına alternatif olarak, giriş tokeninizi buraya doğrudan girebilir veya güncelleyebilirsiniz. Örneğin, bu, birden fazla cihazda girişi hızlandırabilir. Lütfen, uygulamanın ayrıştıramadığı geçersiz token formatlarının kabul edilmeyeceğini unutmayın Genel Proxy Varsayılan kütüphane çipini değiştir Hızlı seçimleri ayarla Son dinlenen şarkıya göre Uygulama dili Benzer içeriği etkinleştir Kuyruğun sonuna ulaşıldığında otomatik olarak daha fazla benzer şarkı ekleyin %d%% Tüm önbelleğe alınan şarkıları temizlemek istediğinizden emin misiniz? Tüm indirmeleri temizlemek istediğinizden emin misiniz? YouTube\'a giriş yapılmadı Desteklenen bağlantıları aç Uygulama ayarları açılamadı Sürüm notları Tüm zamanlar Son 24 saat Son hafta Son ay Son yıl Benim En İyiler listesi uzunluğu Geçmiş süresi Bilgi Açıklama Görüntülemeler Beğeniler Beğenilmeyenler 1 saniye %d saniye Lütfen bekleyin İptal Şarkı sözlerini paylaş Metin olarak paylaş Görsel olarak paylaş Maksimum seçim sınırı Seçilenleri paylaş Renkleri düzenle Metin rengi İkincil metin rengi Arka plan rengi Yeni oynatıcı tasarımı Şarkı sözlerini otomatik kaydır Japonca şarkı sözlerini Latinleştir Korece şarkı sözlerini Latinleştir Hesapla otomatik olarak senkronize et Daha fazla içerik Bir \"m3u\" oynatma listesi içe aktar CSV oynatma listelerini içe aktarın Not: Yerel şarkıların, senkronize edilmiş/uzaktan oynatma listelerine eklenmesi desteklenmemektedir. Bunun dışındaki herhangi bir kombinasyon geçerlidir Beğendiğimde otomatik indir Beğenilen şarkıları otomatik indir Mini oynatıcı kaydırma hassasiyeti %1$d%% Önbelleğe alınmış tüm görüntüleri temizlemek istediğinizden emin misiniz? Devre dışı bırak Abone ol Abone olundu Görüntü oluşturuluyor Şimdi Oynatılıyor Yeni mini oynatıcı tasarımı Kapat +%1$d saniye ileri -%1$d saniye geri Kademeli atlama Eğer etkinleştirilirse, her ileri/geri sarma atlamasında kademeli olarak fazladan 5 saniye ekler Tümünü tekrarla açıkken daha fazla yükle seçeneğini devre dışı bırak Tümünü tekrarla modu açıkken daha fazla şarkı veya benzer içerik yükleme Oynatıcı Küçük Resmini Gizle Oynatıcıdaki albüm resmini uygulama logosuyla değiştirin Arayüz Gizlilik ve Güvenlik Oynatıcı ve İçerik Depolama ve Veri Sistem ve Hakkında Radyo başlatılıyor Oynatma listesi kapağını düzenle Not: Oynatma listesi kapağını değiştirmek için hesabınızın bir telefon numarasına bağlı olması ve YouTube Music\'te doğrulanmış olması gerekir. Bir resim seçtikten sonra lütfen yeni kapağın oynatma listenizde görünmesi için bir süre bekleyin. Kütüphaneden seç Özelleştirilmiş görseli sil Proxy ayarla Proxy kullanıcı adı Proxy şifresi Kimlik doğrulamayı etkinleştir Kiril Latinleştir Şarkı sözlerini Latinleştir Rusça şarkı sözlerini Latinleştir Ukraynaca şarkı sözlerini Latinleştir Belarusça şarkı sözlerini Latinleştir Kırgızca şarkı sözlerini Latinleştir Sırpça şarkı sözlerini Latinleştir Bulgarca şarkı sözlerini Latinleştir DENEYSEL: Her satırın dilini ayrı ayrı algıla Kiril dili, şarkının tamamı yerine satır satır algılanacaktır. Emin misiniz? Bu, başarılı veya başarısız olabilecek deneysel bir özelliktir.\n\nVarsayılan olarak, dil tüm şarkı üzerinden belirlenir; ancak bu seçenek açık olduğunda, dil satır satır belirlenecektir. Bu sayede çok dilli şarkılar da çalışabilir, ama dil her zaman doğru olmayabilir (örneğin, Ukraynaca bir satır Ukraynaca’ya özgü harf içermiyorsa, yanlışlıkla Rusça olarak Latinleştirilebilir).\n\nSorun yaşamıyorsanız, bu seçeneği kapalı bırakmanız önerilir. Geçerli parçayı Latinleştir Offload\'u etkinleştir Ses çalmak için offload ses yolunu kullan. Bu seçeneği devre dışı bırakmak güç tüketimini artırabilir, ancak ses çalma veya sonrası işleme ile ilgili sorunlar yaşıyorsanız faydalı olabilir Yüklendi Yüklendi \"Yüklenen\" oynatma listesini göster Güncelleyici Güncellemeleri otomatik olarak kontrol et Güncelleme bildirimlerini etkinleştir Güncelleme mevcut Uygulama güncellemeleri Yeni sürümlerle ilgili bildirimler Makedonca şarkı sözlerini Latinleştir Durum yerine ayrıntıları kullan Sanatçı adları yerine şarkı adını belirgin şekilde göster Entegrasyonlar Kullanıcı adı Şifre Last.fm Entegrasyonu Scrobbling\'i etkinleştir Şimdi çalanı gönder Scrobbling Yapılandırması Şu süreden uzun şarkıları kaydet Scrobble gecikme yüzdesi Scrobble gecikme dakikaları Şarkıyı oynatma listesinden kaldırmak için kaydırın Beğenilenler/Beğenilmeyenleri Gönder Metrolist\'te Beğenilen/Beğenilmeyen şarkıları Last.fm\'de de Beğen/Beğenme Ana renk Yeniden senkronize et Çince şarkı sözlerini Latinleştir Google Cast Chromecast ve diğer Cast özellikli cihazlara ses aktarımını etkinleştirin Video şarkılarını gizle Şarkının bilgilerini görüntüle Başlığı veya sanatçıyı değiştirme Bu öğeyi temel alarak bir istasyon oluşturun Sıranızın başına ekleyin Sıranızın en altına ekleyin Kitaplığınıza kaydedin Çevrimdışı oynatma için kullanılabilir hale getir Oynatma listelerinizden birine ekleyin YouTube Music\'ten en son meta verileri alın Bu öğenin bağlantısını paylaşın Bu öğeyi kalıcı olarak kaldır Şarkının temposunu ve perdesini değiştirin Ses ekolayzırını ayarlayın Dinamik simgeyi etkinleştir Mini oynatıcı Saf siyah mini oynatıcı Bekle! Uygulamanın şu an kullandığı boyuttan (%1$s) daha küçük bir önbellek sınırı seçtiniz. Devam ederseniz uygulama, yeni sınıra uymak için önbelleğe alınmış bazı %2$s içeriklerini silebilir. Yine de devam edilsin mi? Devam et Üçüncül renk Giriş yapılıyor… Çevrimdışı dinlemek için tüm şarkıları indir İndirilen tüm şarkıları bu oynatma listesinden kaldır İndirme işlemi devam ediyor Bu oynatma listesini başkalarıyla paylaş Bu oynatma listesini kalıcı olarak sil Oynatma listesini YouTube Music ile senkronize et Better Lyrics\'i Etkinleştir Herhangi bir şarkı için karaoke tarzı, hece senkronizasyonlu sözler Kelime kelime animasyon stili Hiçbiri Soluklaşmış Parıltılı Kaydır Karaoke Apple Müzik Şarkı sözü metin boyutu Şarkı sözü satır aralığı Oynatma listesini/albümü önce karıştır Şarkıları karıştırırken, önce orijinal oynatma listesindeki/albümdeki tüm şarkıları, ardından benzer içerikteki şarkıları çalın Özet kartını göster %s için albüm kapağı Şunları dinledin benzersiz albümler En iyi albümünüz Kişisel oynatma listeniz hazır En iyi 5 albümünüz Bu albümü %d dakika dinledin %d dakika Veri yok Yılın en iyi sanatçıları %d dakika Yılın en iyi şarkıları Albüm görseli Yılın en iyi sanatçısı En iyi sanatçı görseli Onları %d dakika boyunca dinlediniz En çok çaldığınız şarkı %d dakika boyunca dinlediniz Şunları dinlediniz benzersiz sanatçılar Şunları dinlediniz benzersiz şarkılar METROLIST Dinlediklerinize göz atmanın zamanı geldi hadi başlayalım! Metrolist Logosu 2025 ÖZETİNİZ HAZIR! Bu yıl neleri sevdiğini görme zamanı. Dinlediğin için teşekkürler Metrolist\'i oluşturduğu için MO Agamy\'ye özel teşekkürler Özeti kapat %s Özetin Oynatma listesi oluştur Oynatma listesi kaydedildi %s cihazına yansıtılıyor İlerleme %s%% Metrolist dinleniyor Görsel oluşturulamadı: %s Başlık kopyalandı Sanatçı kopyalandı Oynatma hatası Proxy URL\'si ayrıştırılamadı. Parıltılı şarkı sözü efektini aktif et Aktif şarkı sözlerine parlama animasyonu ve zıplama efekti ekle Dalgalı %d Profil %d Profiller Ekolayzer Ekolayzır profili yok Profili İçe Aktar Devre dışı %d Gurup %d Guruplar Profili Sil %1$s profilini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. Dosya okunamadı Dosya açılamadı: %1$s İçe Aktarma Hatası Medya sesi kapatıldığında müziği duraklat SimpMusic\'in şarkı sözlerini etkinleştir Şarkı sözleri Musixmatch ve YouTube Transcript üzerinden otomatik olarak sağlanmıştır Karışık listeyi hatırla ve tekrarla Karışık listeyi hatırla ve uygulama yeniden başlatıldığında tekrarla Sistem Ekolayzeri Albüm kapağı Oynatılan şarkı yok Metrolist\'i açmak için bas Bir önceki Devam Et/Duraklat Bir sonraki Beğen Oynatıcı widgetı için arkaplan kontrolcüsü Şarkıyı oynatmak ve değiştirmek yapılmış için dairesel müzik widget\'ı Şarkı sözünün gecikme süresi Şarkıların sessiz kısımlarını hızlıca atla Sessiz kısımları hemen atla Şarkı hızını arttırmadan şarkının sessiz kısımları atla Hakkında Daha fazla göster Daha az göster Sanatçı sayfası Sanatçının açıklamasını göster Abone sayısını göster Aylık dinleyicileri göster Sürekli karıştırma Yeni şarkılar veya oynatma listeleri başlatırken karışık çalma özelliğini açık tutun Oynatma başarısız Hata Ekolayzır profilini kaydederken sorun oluştu: %1$s Albüm Resmini Kırp Video küçük resimlerini kırparak kare en boy oranını zorlayın Oynatıcı genişletildiğinde ekranı açık tut Birlikte Dinle Sunucu URL\'si Kullanıcı adı Bağlanıldı Yeniden bağlanılıyor… Bağlantı kesildi Bağlanılıyor… Bağlantı hatası Oda oluştur Oda oluştur ve kodunu arkadaşlarınla paylaş Odaya katıl Oda kodu Sen sunucu sahibisin Sen misafirsin Katılma isteği Logları görüntüle Bağlantı ve mesajların hatalarını ayıkla Bağlantı logları Log bulunmamakta Gerçek zamanlı olarak arkadaşlarınızla müzik dinleyin. Sunucu sahibi olarak bir oda oluştur un veya oda kodu ile var olan bir odaya katılın. Not: Müzik çalmazken bir oda oluşturursanız ve başka bir uygulamaya geçerseniz bağlantınız kesilebilir. Başkaları ile dinleme ayarlanmadı. Lütfen sunucu URL\'sini Ayarlar → Entegrasyonlar → Başkalarıyla dinle konumuna giderek ayarlayın. %1$s, %2$s\'e talepte bulundu Sunucu sahibine öneride bulun! %1$s sunucuya katılmak istiyor Başkaları ile dinle Başkaları ile dinleme etkinliği için bildirimler Oda oluşturuldu: %s Bir odadayken kullanıcı adı değiştirilemez Sunucu sahibi tarafından onay bekleniliyor Geçersiz oda kodu Katılma isteği reddedildi Var olan bir odaya katıl Oda kodu Odadan ayrıl Katıl Oluştur %s odasına giriliyor… Oda oluşturuluyor… Bağlan Bağlantıyı kes Oluştur Katıl Onayla Reddet Temizle Kopyala Panoya kopyalandı Ayarlanmadı Paylaşılan oda Odada Beklemedeki istekler Bekleyen öneriler Sunucu sahibine öner At Sunucu Sen Bağlanmış kullanıcılar Kullanıcı adını gir Kullanıcı adı zorunludur. Yeniden eşitle Sesi kapat Sesi geri aç Uygulamada sorun oluştu Beklenmedik bir hata oluştu. Sorunu çözebilmemiz için lütfen bize oluşan hatayı raporlayın. Logları paylaş Hata raporlarını paylaş Metrolist Hata Bildirimi Kapat Hata logları bulunmamakta Dinamik Kıpkırmızı Gül Pembesi Mor Koyu Mor Çivit Mavisi Mavi Gök Mavisi Camgöbeği Turkuaz Yeşil Açık Yeşil Limon Sarısı Sarı Kehribar Sarısı Turuncu Koyu Turuncu Kahverengi Gri Mavi Gri Geri Saf Siyah modu Aydınlık modu Karanlık modu Sistem modu %1$s paleti Sunucu sesini eşitle Özel sunucu kullan Özel sunucu Sunucu seç Misafirler sunucu sahibinin ses seviyesini takip eder Katılım isteklerini manuel olarak incelemek yerine otomatik olarak onaylayın Katılım isteklerini otomatik olarak onayla Kodu kopyala Bu kişiyi oturumdan kaldır Kalıcı Olarak Engelle Bu kişinin katılma isteklerini engelle ve önerilerini gizle Engellenen kullanıcı yok %d kullanıcı engellendi Engellenen Kullanıcılar Kullanıcıyı Yönet Bu kişiyi odanın sunucu sahibi yapın Engellemeyi kaldır Kullanıcı sunucu sahibi tarafından engellendi Sahipliği Aktar Herhangi bir şarkı oynatılmıyor Metrolist\'i açmak için tıkla Müzik oynatıcı Dönen plak Beraber dinle Oda kodu gir Sunucuyu, kullanıcı adını, ve daha fazlasını ayarlayın Yapay zeka ile şarkı sözü çevirisi Sözler çeviriliyor... Sözler çevirildi Sağlayıcı Temel URL API Anahtarı Model Çeviri modu Hedef Dil API kimliği Çeviri Transkripsiyon API anahtarına ihtiyaç var API anahtarı zorunludur Çevirilecek söz bulunmamakta Şarkı sözü bulunmamakta Hedef dilin seçilmesi gerekiyor Beklenmeyen çeviri sonucu Bilinmeyen bir hata oluştu Çeviride sorun oluştu Tümünü oynat Müziği tanımla YouTube URL Sütunu (İsteğe bağlı) Yeniden dinle Tüm tanımlama geçmişini silmek istediğinize emin misiniz? Eşleşme bulunamadı Geçmişten sil Sanatçı adı sütunu İşleniyor… Tanımlama geçmişini temizle CSV Sütunlarını Eşleştir Sütun %d Tanımlama hatası Ekranı desteklenen en yüksek yenileme hızında çalışmaya zorla (örn. 120Hz) İlk satır başlıktır Yeniden dene Tanımlamak için dokunun Tanımlama geçmişi Yüksek yenileme hızını etkinleştir Şarkı adı sütunu Son Dönüştürülenler CSV içe aktarılıyor Metrolist\'te Oynat Dinleniliyor… Devam et Etkinleştir Çapraz geçiş Şarkılar arasında çapraz geçiş Çapraz geçiş süresi Aralıksız albümler için bunu kapat Albüm kesintisiz ise çapraz geçişi kullanma Beta Özellikleri Çapraz geçiş yeni bir özelliktir ve hatalar içerebilir. Bir hata ile karşılaşırsanız lütfen bildirin.\n\nBu özellik, bazı teknik kısıtlamalar nedeniyle sesin donanıma devredilmesini devre dışı bırakır (ses, DSP yerine CPU üzerinde işlenir). Çapraz geçiş aktif olduğu için kapatıldı YouTube Shorts\'u gizle Üst çubuktan Başkaları İle Dinle Başkaları ile dinlemeyi navigasyon yerine üst çubukta göster Sırada birden fazla şarkı olmasını önle Eğer listeye yeni bir şarkı eklenmişse ve daha önceden de listede varsa o şarkıyı kaldır Hedef dile uygun bir şekilde çevir Belirlenmiş alfabeye göre tellaffuz edilecek şekilde çevir API anahtarı al https://openrouter.ai sitesinden ücretli ve ücretsiz modellerini görüntüle https://platform.openai.com/api-keys sitesini ziyaret et https://console.anthropic.com/settings/keys sitesini ziyaret et https://aistudio.google.com/apikey sitesini ziyaret et https://perplexity.ai/settings/api sitesini ziyaret et https://console.x.ai sitesini ziyaret et https://deepl.com/pro-api sitesini ücretli ve ücretsiz planlarını görmek için ziyaret et Formalite Varsayılan Daha resmi Az resmi Durum bilgisi Çevrim içi Boşta Rahatsız etmeyin Butonlar 1. tuş 2. tuş Giriş başarılı! Bu özellik, Discord Gateway’e bağlanmak ve Rich Presence durumunuzu ayarlamak için KizzyRPC kütüphanesini kullanır.Benzer kullanımlar nedeniyle bilinen bir hesap askıya alma durumu yaşanmamış olsa da, bu yöntem Discord tarafından resmi olarak desteklenmemektedir ve Kullanım Koşulları ihlali sayılabilir.Tokeniniz yalnızca cihazınızda yerel olarak alınır ve üçüncü taraf sunuculara gönderilmez.Kullanım sorumluluğu tamamen size aittir. Etkinlik türü Oynuyor Dinliyor İzliyor Rekabet Değişkenler: {song_name}, {artist_name}, {album_name} Rich Presence Önizlemesi Durum Ne dinlediğini paylaşmak için Discord\'a giriş yap Metrolist oynuyor Metrolist izliyor Metrolist\'te yarışıyor Etkinlik adı Etkinlik için özel ad (varsayılan için boş bırakın) Gelişmiş mod Rich Presence için ek özelleştirme seçeneklerini göster Düz Bluetooth bağlandığında oynatmaya devam et Hintçe şarkı sözlerini Latinleştir Pencapça şarkı sözlerini Latinleştir Latin harfleriyle yazılmış sözleri ana metin olarak göster Ekran yoğunluğu Yeniden başlat Yeniden başlatma gerekli Ekran yoğunluğu değişikliği, uygulamayı yeniden başlattıktan sonra geçerli olacaktır. Şimdi yeniden başlatmak ister misiniz? Topluluk odaklı senkronize şarkı sözü veritabanı Şarkı sözleri, popüler Çin müzik platformu KuGou\'dan alınır NOT: Diğer şarkı sözleri mevcut olmadığında YouTube Music\'ten şarkı sözleri otomatik olarak gösterilecektir. YTM\'den alınan şarkı sözleri genellikle senkronize değildir. LyricsPlus\'ı etkinleştir Birden fazla kaynaktan senkronize edilmiş şarkı sözleri Sağlayıcı seçimi Hangi şarkı sözü sağlayıcılarının etkinleştirileceğini seçin Şarkı sözü sağlayıcı önceliği Tercihinize göre sağlayıcıları yeniden sıralamak için sürükleyin. Daha üst sıra -> daha yüksek öncelik. Değişiklik Günlüğü Değişiklik günlüğü mevcut değil https://github.com/MetrolistGroup/Metrolist/releases GitHub\'da görüntüle Mevcut sürüm Sürüm: %s Ayarları güncelle Güncellemeleri kontrol et Güncellemeler kontrol ediliyor… Güncel: %s Güncellemeleri kontrol et Değişiklik günlüğünü gizle Değişiklik günlüğünü görüntüle Güncellemeleri kontrol etme başarısız oldu: %s Varsayılan olarak ayarla Uyku zamanlayıcısı varsayılan olarak %d dk olarak ayarlanmıştır Ayarlar > İçerik bölümünde bulunur oynatılanlar Bölüm kaydedilemedi Bölüm kaldırılamadı Podcast\'e abone olma işlemi başarısız oldu Podcast aboneliğinden çıkma işlemi başarısız oldu Şarkı önerilerini otomatik olarak onayla Konuklardan gelen şarkı önerilerini otomatik olarak onaylayın ve sıraya alın Hızlı arama Hızlı arama için sabitle Hızlı aramadan sabitlemeyi kaldır Ana Ekran Sırasını Rastgeleleştir Ana ekran bölümlerinin sırasını ağırlıklı önceliklere göre rastgele değiştirin %1$s tarzında %1$s dinlediğin için %1$s benzeri %1$s baz alınarak %1$s fanları için Topluluktan Kütüphane verileri saklansın mı? Oynatma listelerinizi ve kütüphane verilerinizi saklamak istiyor musunuz? İndirilen şarkılar her durumda saklanacaktır. Sakla Temizle Baş Geliştirici Katkıda bulunan Katkıda bulunanlar GNU Genel Açık Laynak Lisansı v3.0 Ücretsiz, açık kaynaklı yazılım. Kullanabilir, inceleyebilir, paylaşabilir ve geliştirebilirsiniz. Discord Sunucusu Telegram Kanalı Web sitesi Instagram GitHub Depoyu Görüntüle %1$s • %2$s Yaptıklarımdan hoşlanıyor musunuz? Bana bir kahve ısmarla Topluluk ve Bilgi METROLIST Onların en sevdiği şarkıyı çalmak ister misin? Evet Bu proje Filistin\'in yanındadır 🇵🇸 Podcastler Podcast\'i görüntüle Podcast Kanalları Son bölümler Sizin Programlarınız Yeni bölümler Daha Sonraki Bölümler Daha sonrası için kaydet Bölümlerinizi \'Daha Sonra Dinle\' listenize ekleyin Kaydedilenlerden kaldır Podcast\'i kütüphaneye kaydet %d bölüm %d bölüm Yedeklemeyi geri yükle? Bu seçenekle uygulama verilerinizi yedekten geri yükleyebilirsiniz. Geri yükleme işleminden sonra tekrar giriş yapmanız gerekecek. Aşağıdaki hesaptan çıkış yapılacaktır: Geri yükle Önceki hesap kontrol ediliyor… Hesap bulunamadı Oynatma listesi içe aktarılıyor Müzik Tanıyıcı Çevrenizde çalan şarkıları doğrudan ana ekranınızdan tanımlayın Şarkıyı tanımlamak için dokunun Dinliyor… Tanımlanıyor… Eşleşme bulunamadı. Tekrar deneyin Tanıma başarısız oldu Bir hata oluştu. Lütfen tekrar deneyin Bilinmeyen şarkı Bilinmeyen sanatçı Şarkıyı tanımla Müzik Tanıma Widget\'tan bir şarkı tanımlanırken bildirim gösterilir Şarkıyı belirlemek için ses kaydı yapılıyor… Bölümler Kanallar Otomatik oynatma listesi İndirilen bölümler Abone olunan kanal yok İndirilen bölüm yok %d kanal %d kanal Kanalı görüntüle Profiller Uyku zamanlayıcısını otomatikman etkinleştir Varsayılan değer özel bir zaman ise uyku zamanlayıcısını otomatik olarak aktif eder Uyku zamaayıcısının otomatikman aktif edilmesi için özel gün ve zamanı ayarlayın Tekrar Günlük Pazartesiden cumaya kadar Hafta içi / Hafta sonu Hafta sonları (Cmt-Pzr) Özel Başlangıç vakti Bitiş vakti Pazartesi Salı Çarşamba Perşembe Cuma Cumartesi Pazar Zamanlayıcının süresi dolduğunda o sırada çalınan şarkıyı durdur Son dakikalarda ses yavaşça azaltılır Şarkıları yükle Yükleniyor… %1$d / %2$d Yükleme tamamlandı Yükleme başarısız oldu Dosya çok büyük (maksimum 300 MB) Desteklenmeyen format. Lütfen mp3, m4a, wma, flac veya ogg kullanın Yüklenen şarkıyı sil Yüklediğiniz bu şarkıyı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. Yüklenen şarkı silindi Yüklenen şarkı silinemedi Yüklenen şarkıları sil Yüklediğiniz %1$d şarkıyı silmek istediğinizden emin misiniz? Bu işlem geri alınamaz. Silinen %1$d şarkı Siliniyor… Müziği Tanı Belgelere Kaydet Paylaş Oynatma listesi dışa aktarılamadı Oynatma listesi başarıyla dışa aktarıldı M3U olarak dışa aktar CSV olarak dışa aktar Oynatma listesini dışa aktar ================================================ FILE: app/src/main/res/values-tr/strings.xml ================================================ Ana Sayfa Şarkılar Sanatçılar Albümler Çalma Listeleri %d seçildi %d seçililer Geçmiş İstatistikler Ruh Hali ve Türler Hesap Hızlı seçimler Hızlı seçimlerinizi oluşturmak için birkaç şarkı dinleyin Yeni çıkan albümler Bugün Dün Bu hafta Geçen hafta En çok dinlenen şarkılar En çok dinlenen sanatçılar En çok dinlenen albümler Arama YouTube Müzik\'te Ara… Kütüphanede ara… Kütüphane Beğenilenler İndirilenler Hepsi Şarkılar Videolar Albümler Sanatçılar Çalma Listeleri Topluluk çalma listeleri Öne çıkan listeler Favoriler Sonuç bulunamadı Kütüphanenizden Beğenilen şarkılar İndirilen şarkılar Çalma listesi boş Tekrar dene Radyo Karıştır Sıfırla Detaylar Düzenle Radyo başlat Çal Bir sonra çal Sıraya ekle Kütüphaneye ekle Kütüphaneden kaldır İndir İndiriliyor İndirmeyi kaldır Çalma listesini içe aktar Çalma listesine ekle Sanatçıyı görüntüle Albümü görüntüle Yenile Paylaş Sil Geçmişten kaldır Çevrim içi ara Eşitle Gelişmiş Eklendiği tarih Ad Sanatçı Yıl Şarkı sayısı Uzunluk Çalma süresi Özel sıralama Medya kimliği MIME türü Kodekler Bit hızı Örnek hızı Ses şiddeti Ses yüksekliği Dosya boyutu Bilinmiyor Panoya kopyalandı Sözleri düzenle Sözleri ara Şarkıyı düzenle Şarkı adı Şarkı sanatçıları Şarkı adı boş olamaz. Şarkı sanatçısı boş olamaz. Kaydet Çalma listesi seç Çalma listesini düzenle Çalma listesi oluştur Çalma listesi adı Çalma listesi adı boş olamaz. Sanatçıyı düzenle Sanatçı adı Sanatçı adı boş olamaz. %d şarkı %d şarkılar %d sanatçı %d sanatçılar %d albüm %d albümler %d çalma listesi %d çalma listeleri %d hafta %d haftalar %d ay %d aylar %d yıl %d yıllar Çalma listesi içe aktarıldı \"%s\" çalma listesinden kaldırıldı Çalma listesi eşitlendi Geri al Şarkı sözleri bulunamadı Uyku zamanlayıcısı Şarkının sonu 1 dakika %d dakika Akış yok Ağ bağlantısı yok Zaman aşımı Bilinmeyen hata Beğen Beğeniyi kaldır Karıştırma açık Karıştırma kapalı Tekrarlama modu kapalı Şu anki şarkıyı tekrarla Sırayı tekrarla Tüm şarkılar Aranan şarkılar Müzik Çalar Ayarlar Görünüm Dinamik temayı etkinleştir Koyu tema Açık Kapalı Sistemi izle Simsiyah Varsayılan giriş sekmesi Gezinti sekmelerini özelleştir Şarkı sözleri konumu Sol Orta Sağ İçerik Oturum aç Varsayılan içerik dili Varsayılan ülke içeriği Sistem varsayılanı Proxy\'yi etkinleştir Proxy türü Proxy URL\'si Etkinleştirmek için yeniden başlatın Müzik çalar ve ses Ses kalitesi Otomatik Yüksek Düşük Kalıcı şarkı sırası Sessizliği atla Ses normalleştirme Ekolayzer Depolama Önbellek Görüntü Önbelleği Şarkı Önbelleği Maksimum önbellek boyutu Sınırsız Tüm indirilenleri temizle Maksimum görüntü önbellek boyutu Görüntü önbelleğini temizle Maksimum şarkı önbellek boyutu Şarkı önbelleğini temizle %s kullanıldı Gizlilik Dinleme geçmişini duraklat Dinleme geçmişini temizle Tüm dinleme geçmişini temizlemekten emin misiniz? Arama geçmişini duraklat Arama geçmişini temizle Tüm arama geçmişini temizlemekten emin misiniz? LrcLib şarkı sözü sağlayıcısını etkinleştir KuGou şarkı sözü sağlayıcısını etkinleştir Yedekleme ve geri yükleme Yedekle Geri yükle Çalma listesi içe aktarıldı Yedekleme başarıyla oluşturuldu Yedekleme oluşturulamadı Yedekleme geri yüklenemedi Hakkında Uygulama sürümü Yeni sürüm mevcut Çeviri Modelleri Çeviri modellerini temizle Büyük Seçenekler İptal Daha fazla şarkıyı otomatik ekle Ön izleme Tema Oturum açma başarısız Bu özellik etkinleştirildiğinde ekran görüntüleri ve son kullanılanlardaki uygulamalar görünümü devre dışı bırakılır. Ekran görüntüsünü devre dışı bırak Sıradan çıkar Sıranın sonuna gelindiğinde mümkünse otomatik olarak daha fazla şarkı ekler Hepsini kütüphaneden kaldır Oynatıcı Yinelenenler Şarkı zaten çalma listenizde var YouTube çalma listeleriniz Çalma listesinden kaldır Oynatıcı metin hizalaması Oynatıcı kaydırma biçimi Sıra Uygulama başladığında son şarkı sırasını geri getirir Hata oluştuğunda sonraki şarkıya otomatik atla Dinleme geçmişi Unutulan favoriler Dinlemeye devam edin Benzerleri Kütüphane şarkıları burada görünecektir Kütüphane sanatçıları burada görünecektir Kütüphane albümleri burada görünecektir Çalma listeleriniz burada görünecektir Diğer versiyonlar İndirilen şarkılar deposundaki \"%s\" şarkının hepsini silmek istiyor musunuz? \"%s\" çalma listesini silmek istiyor musunuz? Hepsini kütüphaneye ekle Tempo ve Perde Yinelemeleri atla Yine de ekle %d şarkı zaten çalma listenizde Hepsini beğen Tüm beğenileri kaldır Kenarlı Varsayılan Dalgalı Çeşitli Izgara boyutu Küçük Oturum açılmadı Kesintisiz dinleme deneyimi sağlar Arka plandan temizlendiğinde çalmayı durdur Arama geçmişi Uygunsuz içeriği gizle Discord Entegrasyonu Metrolist, Discord hesabınızın durumunu ayarlamak için KizzyRPC kütüphanesini kullanır. Bu, Discord\'un Hizmet Şartları\'nın ihlali olarak kabul edilebilecek Discord Geçit bağlantısının kullanılmasını gerektirir. Fakat kullanıcı hesaplarının bu nedenle askıya alındığı bilinen bir örnek yoktur. Riski size ait olmak üzere kullanın. \n \nMetrolist yalnızca belirtecinizi çıkarır ve diğer her şey yerel olarak saklanır. Oturumu kapat Rich Presence\'ı etkinleştir İçerikleri görmek için Giriş yap\'ı kullanın Bu gördüğünüz içerikleri etkileyebilir ve örnek olarak Premium\'a özel içerikleri Premium\'a sahip bir hesapla giriş yaptıysanız size sunabilir Giriş Yap ================================================ FILE: app/src/main/res/values-uk/metrolist_strings.xml ================================================ Локально Віддалено Тижні Місяці Роки Безперервно Вподобане Завантажене Мій Топ Синхронізувати список відтворення Зауважте: Це дозволяє синхронізацію з YouTube Music. Це НЕ можна буде змінити пізніше. Обрати все Вподобати все Позначити все як не вподобане Нещодавно оновлені Текст Вже в списку відтворення: %d раз %d рази %d разів %d разів Подібний контент Стиль фону програвача Дотримуватись теми Градієнт Розмиття Перемикання пісень проведенням по обкладинці Змінити текст пісні при натисканні Тонка нижня панель навігації Вхід через токен Натисніть для показу токену Повторно натисніть, щоб скопіювати або редагувати Це ПРОСУНУТИЙ метод входу в систему. Альтернативно до веб-порталу, ви можете безпосередньо ввести або оновити свій токен для входу тут. Наприклад, це може прискорити вхід на декількох пристроях. Зверніть увагу, що будь-які недійсні формати токенів, які додаток не зможе розпізнати, не будуть прийняті Загальне Змінити головну сторінку бібліотеки Налаштувати швидкі добірки На основі останньої прослуханої пісні Мова застосунку Увімкнути подібний контент Автоматично додавати більше схожих пісень, коли буде досягнуто кінця черги Відкрити підтримувані посилання Не вдалося відкрити налаштування застосунку Примітки релізу Весь час Минулі 24 години Минулий тиждень Минулий місяць Минулий рік Довжина мого топ-списку Довжина історії %d секунда %d секунди %d cекунд %d cекунд Чарти Назад Обкладинка альбому Найкращі музичні відео Тренди Кешовано Генерація зображення Синхронізація вимкнена Зачекайте, будь ласка Скасувати Поділитися текстом пісні Поділитися у вигляді тексту Поділитися у вигляді зображення Максимальний ліміт вибору Поділитися вибраним Налаштувати кольори Колір тексту Вторинний колір тексту Колір тла Видалити з кешу Скопіювати посилання Посилання скопійовано в буфер обміну Кольори кнопок програвача За замовчуванням Показати список відтворення \"Завантажене\" Проведіть пісню вліво, щоб додати її до черги, або вправо — щоб відтворити наступною Тонкий Автоматичні списки відтворення Показати список відтворення \"Топ\" Показати кешований список відтворення Показати список відтворення \"Вподобане\" Проксі %d%% Автоматично завантажувати вподобані пісні Автоматично завантажувати пісні, які ви вподобали Ви впевнені, що хочете очистити всі кешовані пісні? Ви впевнені, що хочете очистити всі завантаження? Ви не ввійшли в YouTube Інформація Опис Перегляди Вподобання Невподобання Автопрокрутка тексту пісні Імпортувати список відтворення у форматі \"m3u\" Імпортувати список відтворення у форматі \"csv\" Примітка: додавання локальних пісень до синхронізованих або віддалених списків відтворення не підтримується. Усі інші комбінації можливі Новий дизайн програвача Романізувати Японські тексти Романізувати Корейські тексти Автосинхронізація з акаунтом Більше контенту Чутливість проведення по мініпрогравачу %1$d%% Ви дійсно хочете очистити всі кешовані зображення? Вимкнути Підписатися Підписані Новий дизайн мініпрогравача Зараз грає +%1$d сек. вперед -%1$d сек. назад Прогресивний пошук Якщо ввімкнено, додає 5 додаткових секунд при кожному пропусканні пошуку Закрити Вимкнути завантаження додаткових параметрів під час повторення всіх Не завантажувати автоматично більше пісень та подібного контенту, коли ввімкнено режим повторення всіх Приховати мініатюру програвача Замінити обкладинку альбому логотипом додатка в програвачі Інтерфейс Конфіденційність та безпека Плеєр і контент Зберігання та дані Система та про нас Запуск радіо Налаштувати проксі-сервер Ім\'я користувача проксі-сервера Пароль проксі-сервера Увімкнути автентифікацію Кирилица Романізація Романізація текстів пісень Романізувати Російські тексти Романізувати Українську лірику Романізувати Білоруські тексти пісень Романізувати Киргизькі тексти пісень Романізувати Сербські тексти пісень Романізувати Болгарські тексти пісень ЕКСПЕРИМЕНТАЛЬНА ВЕРСІЯ: Визначення мови рядок за рядком Кирилична мова буде розпізнаватися рядок за рядком, а не по всій пісні. Ви впевнені? Це експериментальна функція, яка може працювати як з успіхом, так і з невдачею. \n\nЗа замовчуванням мова визначається на основі всієї пісні, але якщо ввімкнути цю опцію, вона буде визначатися по рядках. Це дозволить працювати з багатомовними піснями, АЛЕ мова може бути не завжди правильною (наприклад, якщо в українському тексті немає літер, характерних для української мови, він може бути транслітерований як російський). \n\nЯкщо у вас немає проблем, рекомендується залишити цю опцію вимкненою. Романізувати поточний трек Редагувати обкладинку списку відтворення Примітка: Щоб змінити обкладинку списку відтворення, ваш обліковий запис має бути пов’язаний з номером телефону та підтверджений у YouTube Music. Після вибору зображення зачекайте трохи, поки нова обкладинка з’явиться у вашому списку відтворення. Увімкнути розвантаження Використовуйте шлях розвантаження аудіо для відтворення аудіо. Вимкнення цієї функції може збільшити споживання енергії, але може бути корисним, якщо у вас виникнуть проблеми з відтворенням аудіо або постобробкою Виберіть з бібліотеки Вилучити власне зображення Завантажено Завантаженні Показати список відтворення \"Завантажено\" Оновлення Автоматично перевіряти наявність оновлень Романізувати македонську лірику Увімкнути сповіщення про оновлення Доступне оновлення Оновлення програм Сповіщення про нові версії Використовуйте деталі замість штату Помітно показувати назву пісні замість імен виконавців Інтеграції Ім\'я користувача Пароль Інтеграція з Last.fm Увімкнути скробблінг Надіслати «Зараз відтворюється» Конфігурація скроблювання Пісні Scrobble довші за Відсоток затримки Scrobble Затримка Scrobble у хвилинах Проведіть пальцем по пісні, щоб вилучити її зі списку відтворення Надіслати вподобання/не вподобання Пісні, які подобається/не подобається, на Last.fm, коли вони вподобані/зняті з Metrolist Романізація китайських текстів Google Cast Увімкнути трансляцію аудіо на Chromecast та інші пристрої з підтримкою Cast Приховати відеопісні Основний колір Повторна синхронізація Переглянути інформацію про пісню Змінити назву або виконавця Створити станцію на основі цього елемента Додати на початок черги Додати в кінець черги Зберегти у своїй бібліотеці Зробити доступним для відтворення офлайн Додати до одного зі своїх плейлистів Отримати найновіші метадані з YouTube Music Поділитися посиланням на цей елемент Вилучити цей елемент назавжди Змінити темп і висоту тону пісні Налаштування аудіоеквалайзера Увімкнути динамічний значок Міні-плеєр Чисто чорний міні-плеєр Тримайся! Ви вибрали обмеження розміру кешу, менше за те, що зараз використовує програма (%1$s). Якщо ви продовжите, програма може видалити деякі кешовані %2$s, щоб вони відповідали новому ліміту. Продовжити все одно? Продовжити Третинний колір Вхід до… Завантажте всі пісні для відтворення офлайн Видалити всі завантажені пісні з цього списку відтворення Триває завантаження Поділіться цим плейлистом з іншими Видалити цей плейлист назавжди Синхронізуйте плейлист з YouTube Music Увімкнути кращі тексти пісень Використовуйте постачальника Better Lyrics для синхронізованих текстів пісень слово за словом Стиль анімації слово за словом Жоден Згасання Сяйво Слайд Караоке Apple Music Розмір тексту пісні Міжрядковий інтервал тексту пісень Спочатку перемішати відтворення відтворення/альбому Під час перемішування спочатку відтворювати всі пісні з оригінального списку відтворення/альбому, а потім схожий контент Увімкнути ефект сяйва тексту пісень Додайте анімацію сяйва та ефект відскоку до активних текстів пісень Показати обгорнуту картку Обкладинка альбому для %s Ви слухали унікальні альбоми Ваш найкращий альбом – Ваш особистий плейлист готовий Ваші 5 найкращих альбомів Ви слухали цей альбом протягом %d хвилин %d хвилин Немає даних Ваші найкращі артисти року %d хвилин Ваші найкращі пісні року Обкладинка альбому Ваш найкращий артист року – Найкраще зображення виконавця Ви слухали їх протягом %d хвилин Ваша найпопулярніша пісня — Ви слухали %d хвилин Ти слухав/слухала унікальні митці Ти слухав/слухала унікальні пісні METROLIST час подивитися, що ви слухали Ходімо! Логотип Metrolist 2025 ВАШ АУДІОПІДСУМОК ЗА РІК ГОТОВИЙ! Час подивитися, що вам сподобалося цього року. Дякую, що вислухали Особлива подяка MO Agamy за створення Metrolist Закрити обгорнуте Ваш %s загорнутий Створити плейлист Список відтворення збережено Трансляція на %s Прогрес %s%% Слухаючи Metrolist Відкрити Не вдалося створити зображення: %s Скопійована назва Скопійований виконавець Помилка відтворення Не вдалося проаналізувати URL-адресу проксі-сервера. %d профіль %d профіли %d профілів %d профілів Еквалайзер Немає профілів еквалайзера Імпортувати профіль Вимкнено %d діапазон %d діапазони %d діапазонів %d діапазонів Видалити профіль Ви впевнені, що хочете видалити %1$s? Цю дію не можна скасувати. Не вдалося прочитати файл Не вдалося відкрити файл: %1$s Помилка імпорту Хвилястий Призупиняти музику, коли медіа вимкнено Увімкнути текст пісні SimpMusic Використовуйте постачальника текстів пісень SimpMusic для синхронізованих текстів пісень Системний еквалайзер Обкладинка альбому Пісня не відтворюється Натисніть, щоб відкрити Metrolist Попередній Відтворення/Пауза Далі Подобається Віджет музичного плеєра з елементами керування відтворенням Швидкий доступ до останньої відтвореної композиції Пам’ятайте про перемішування та повторення Запам\'ятайте режим випадкового відтворення та повтору під час перезапуску програми Текст пісні Offset Про нас Показати більше Показати менше Сторінка виконавця Показати опис виконавця Показати кількість підписників Показати щомісячні слухачі Перемотування вперед тихих частин пісень Миттєво пропустити тишу Перемотуйте вперед під час тихих моментів замість прискорення відтворення Постійне перемішування Увімкніть випадкове перемішування під час запуску нових пісень або списків відтворення Помилка відтворення Помилка Не вдалося застосувати профіль еквалайзера: %1$s Обрізати обкладинку альбому Примусове квадратне співвідношення сторін шляхом обрізання мініатюр відео Залишати екран увімкненим, коли програвач розгорнуто Увімкнути Суцільний Щільність відображення Перезапустити Зміна щільності відображення почне діяти після перезапуску програми. Ви хочете перезапустити зараз? ================================================ FILE: app/src/main/res/values-uk/strings.xml ================================================ Головна Музика Виконавці Альбоми Плейлисти %d вибрано %d вибране %d вибрані %d вибрані Історія Статистика Настрій та жанри Акаунт Швидкий вибір Послухайте кілька пісень, щоб створити ваш швидкий вибір Забуті улюблені Продовжуйте слухати Ваші плейлисти YouTube Схожі на Нові релізи альбомів Сьогодні Вчора Цього тижня Минулого тижня Найпопулярніші пісні Найпопулярніші виконавці Найпопулярніші альбоми Пошук Пошук в YouTube Music… Пошук в бібліотеці… Бібліотека Вподобані Завантажені Всі Композиції Відео Альбоми Виконавці Плейлисти Плейлисти спільноти Обрані плейлисти Додано в закладки Результатів не знайдено Тут будуть відображатися пісні з вашої бібліотеки Тут будуть відображатися виконавці з вашої бібліотеки Тут будуть відображатися альбоми з вашої бібліотеки Тут будуть відображатися ваші плейлисти З вашої бібліотеки Інші версії Улюблені треки Завантажена музика Плейлист порожній Ви впевнені, що хочете видалити всі пісні з плейлиста «%s» зі сховища завантаженої музики? Ви впевнені, що хочете видалити плейлист «%s»? Повторювати Радіо Перемішати Скинути Детальніше Редагувати Увімкнути радіо Відтворити Відтворити наступним Додати в чергу Додати в бібліотеку Додати все до бібліотеки Видалити з бібліотеки Видалити все з бібліотеки Завантаження Завантаження Видалити із завантажених Імпортувати плейлист Додати в плейлист Перейти до виконавця Перейти до альбому Оновити Поділитися Видалити Видалити з історії Видалити з плейлиста Видалити з черги Пошук в Інтернеті Синхронізація Контроль аудіо Темп і висота тону Нещодавно додані Назва Виконавець Рік Кількість треків Тривалість Кількість відтворень Корист. порядок Ідентифікатор медіа Тип MIME Кодеки Бітрейт Частота дискретизації Гучність Рівень гучності Розмір файлу Невідомо Скопійовано Редагувати текст пісні Пошук тексту пісні Редагувати композицію Назва композиції Виконавці композиції Назва пісні не може бути порожньою. Поле \"Виконавець пісні\" не може бути порожнім. Зберегти Вибрати плейлист Редагувати плейлист Створити плейлист Назва плейлиста Назва списку відтворення не може бути порожньою. Редагувати виконавця Ім\'я виконавця Ім\'я виконавця не може бути порожнім. Дублікати Пропустити дублікати Додати в будь-якому разі Ця пісня вже у вашому плейлисті %d пісень вже у вашому плейлисті %d композиція %d композиції %d композицій %d композицій %d виконавець %d виконавця %d виконавців %d виконавців %d альбом %d альбоми %d альбомів %d альбомів %d плейлист %d плейлисти %d плейлистів %d плейлистів %d тиждень %d тижні %d тижнів %d тижнів %d місяць %d місяці %d місяців %d місяців %d рік %d роки %d років %d років Плейлист імпортовано «%s» видалена з плейлиста Плейлист синхронізовано Скасувати Текст пісні не знайдено Таймер сну Кінець пісні %d хвилина %d хвилини %d хвилин %d хвилин Немає доступних потоків Відсутнє підключення до мережі Тайм-аут Невідома помилка Поставити «Подобається» Відзначити все як «Подобається» Прибрати «Подобається» Прибрати всі позначки «Подобається» Увімкнути перемішування Вимкнути перемішування Вимкнути повторення Повторити поточну пісню Повторити чергу Всі композиції Шукані композиції Музичний плеєр Параметри Зовнішній вигляд Тема Увімкнути динамічну тему Темна тема Увімк. Вимк. Використовувати налаштування системи Режим чистого чорного кольору Налаштування вкладок навігації Плеєр Вирівнювання тексту плеєра Розташування тексту пісні Збоку Ліворуч По центру Праворуч Стиль повзунка плеєра Стандартний Хвилястий Різне Основна вкладка навігації Розмір клітинки сітки Малий Великий Контент Логін Не авторизовано Мова контенту Країна контенту Використовувати налаштування системи Увімкнути проксі Тип проксі URL проксі Перезапуск програми Плеєр та аудіо Якість аудіо Авто Висока Низька Черга Постійна черга Відновлювати останню чергу після запуску програми Автозавантаження більшої кількості пісень Автоматично додавати більше пісень при досягненні кінця черги, якщо це можливо Пропуск тиші в композиціях Нормалізація аудіо Автоперехід до наступної пісні при помилці Забезпечити безперервне відтворення Зупиняти музику при очищенні завдань Еквалайзер Сховище Кеш Кеш зображень Кеш аудіо Максимальний розмір кешу Необмежено Очистити всі завантаження Макс. розмір кешу зображень Очистити кеш зображень Макс. розмір кешу аудіо Очистити кеш аудіо %s використано Конфіденційність Історія прослуховувань Призупинити історію прослуховування Очистити історію прослуховування Ви впевнені, що хочете очистити всю історію прослуховування? Призупинити історію пошуку Історія пошуку Очистити історію пошуку Ви впевнені, що хочете очистити всю історію пошуку? Вимкнути знімок екрана Якщо ця опція увімкнена, знімки екрана та відображення програми в списку останніх відключаються. Увімкнути провайдера текстів LrcLib Увімкнути провайдера текстів KuGou Приховувати контент із нецензурною лексикою Резервне Копіювання Резервне копіювання Відновити Імпортований плейлист Резервну копію створено успішно Не вдалося створити резервну копію Не вдалося відновити з резервної копії Інтеграція з Discord Metrolist використовує бібліотеку KizzyRPC для встановлення статусу вашого облікового запису Discord. Це передбачає використання підключення через Discord Gateway, що може вважатися порушенням умов використання Discord. Однак, наразі немає відомих випадків блокування облікових записів користувачів з цієї причини. Використовуйте на свій страх і ризик.\n\nMetrolist буде зчитувати тільки ваш токен, а все інше зберігається локально. Закрити Налаштування Попередній перегляд Помилка входу Вийти Увімкнути Rich Presenсe Про програму Версія застосунку Доступна нова версія Моделі перекладу Очистити моделі перекладу Це може вплинути на те, який вміст ви бачите, і, наприклад, показує лише платні альбоми, якщо ви ввійшли в обліковий запис Premium Використовуйте логін для перегляду вмісту Увійти ================================================ FILE: app/src/main/res/values-v31/styles.xml ================================================ ================================================ FILE: app/src/main/res/values-v31/widget_colors.xml ================================================ @android:color/system_accent1_100 @android:color/system_accent1_900 @android:color/system_accent3_100 @android:color/system_accent3_900 @color/widget_primary_container @color/widget_on_primary_container ================================================ FILE: app/src/main/res/values-vi/metrolist_strings.xml ================================================ Lịch sử nghe cục bộ Lịch sử nghe từ xa Tuần Tháng Năm Liên tục Đã thích Đã tải xuống Top nghe nhiều Đồng bộ danh sách phát Lưu ý: Hành động này sẽ cho phép ứng dụng đồng bộ với YouTube Music, và bạn sẽ không thể hủy bỏ hành động này. Chọn tất cả Thích tất cả Không thích toàn bộ Ngày cập nhật Lời bài hát Đã tồn tại trong danh sách phát: %d lần Nội dung tương tự Dựa theo chủ đề Độ phủ màu Làm mờ Bật lướt bìa để chuyển bài Thay đổi lời bài hát khi nhấp Dạng mỏng Thanh điều hướng mỏng phía dưới Đăng nhập bằng token Chạm để xem Token Chạm lần nữa để sao chép hoặc chỉnh sửa Đây là phương thức đăng nhập NÂNG CAO. Để thay thế cho web, bạn có thể trực tiếp nhập hoặc cập nhật mã Token của mình tại đây. Điều này có thể tăng tốc độ đăng nhập trên nhiều thiết bị. Xin lưu ý rằng mọi định dạng mã Token không hợp lệ mà ứng dụng không phân tích được sẽ không được chấp nhận Nâng cao Proxy Thay đổi thư viện mặc định Đặt lựa chọn nhanh Dựa vào lần cuối bạn đã nghe Ngôn ngữ ứng dụng Bật hiển thị nội dung tương tự Tự động thêm bài hát có nội dung tương tự khi hàng chờ của bạn đã hết %d%% Bạn có muốn xóa tất cả bộ nhớ đệm (cache) liên quan đến nhạc? Bạn có muốn xóa tất cả bài hát đã tải xuống? Chưa đăng nhập vào YouTube Mở đường liên kết được hỗ trợ Không thể mở ứng dụng cài đặt Ghi chú của bản phát hành Mọi lúc 24 giờ trước Tuần trước Tháng trước Năm trước Độ dài danh sách Top của tôi Thời lượng nghe Thông tin chi tiết Mô tả Lượt xem Thích Không thích %d giây Tự động cuộn lời bài hát Hiện Top danh sách phát Hiện danh sách phát tạm thời trong bộ đệm Nhập 1 danh sách phát kiểu định dạng \"csv\" Nhập 1 danh sách phát kiểu định dạng \"m3u\" Biểu đồ Ảnh bìa Album Top MV Đã tắt đồng bộ Huỷ bỏ Chia sẻ dưới dạng văn bản Chia sẻ dưới dạng hình ảnh Giới hạn chọn tối đa Chia sẻ mục đã chọn Tùy chỉnh màu sắc Xoá khỏi bộ đệm Sao chép link Link đã được sao chép vào bộ nhớ đệm Màu của các nút trong trình phát Mặc định Lướt bài nhạc sang bên trái để thêm vào hàng chờ, lướt sang bên phải để phát ngay sau bài hiện đang phát Danh sách phát tự động Hiện danh sách phát đã yêu thích Hiện danh sách phát đã tải về Đang khởi tạo hình ảnh Màu của văn bản Màu của văn bản phụ Màu nền Vui lòng chờ Quay lại Kiểu nền của trình phát Đang xu hướng Chia sẻ lời bài hát Đã lưu vào bộ đệm Lời bài hát tiếng Nhật Lời bài hát tiếng Hàn Tự động đồng bộ với tài khoản Nội dung khác Lưu ý: Không hỗ trợ thêm bài hát cục bộ vào danh sách phát đồng bộ/từ xa. Bất kỳ kết hợp nào khác đều hợp lệ Tự động tải xuống khi thích Tự động tải xuống các bài hát khi bạn thích Thiết kế trình phát mới Độ nhạy vuốt của trình phát mini Bạn có chắc chắn muốn xóa toàn bộ hình ảnh đã lưu trong bộ nhớ đệm không? Vô hiệu hóa Đăng ký Đã đăng ký Đang Phát Đóng +%1$d giây chuyển tiếp -%1$d giây ngược lại Ẩn hình thu nhỏ của trình phát Thay thế ảnh bìa album với logo ứng dụng trong trình phát Thiết kế trình phát mini mới %1$d%% Tắt tải thêm khi lặp lại tất cả Không tự động tải thêm bài hát và nội dung tương tự khi bật chế độ lặp lại tất cả Tua liên tục Nếu được bật, sẽ cộng thêm 5 giây theo từng lần bỏ qua tua Giao diện Quyền riêng tư & Bảo mật Trình phát & Nội dung Lưu trữ & Dữ liệu Hệ thống & Thông tin Radio đang bắt đầu Chỉnh sửa ảnh bìa danh sách phát Lưu ý: Tài khoản của bạn phải được liên kết với số điện thoại và được xác minh trên YouTube Music để thay đổi ảnh bìa danh sách phát. Sau khi chọn hình ảnh, vui lòng đợi để ảnh bìa mới xuất hiện trong danh sách phát của bạn. Cấu hình proxy Tên người dùng proxy Mật khẩu proxy Bật xác thực Cyrillic Lời bài hát Latinh Latinh Lời bài hát tiếng Nga Lời bài hát tiếng Ukraine Lời bài hát tiếng Belarus Lời bài hát tiếng Kyrgyz Lời bài hát tiếng Serbia Lời bài hát tiếng Bulgaria THỬ NGHIỆM: Phát hiện ngôn ngữ theo từng dòng Ngôn ngữ Cyrillic sẽ được phát hiện theo từng dòng thay vì toàn bộ bài hát. Bạn có chắc không? Đây là một tính năng thử nghiệm có thể thành công hoặc thất bại.\n\nTheo mặc định, ngôn ngữ được xác định dựa trên toàn bộ bài hát, nhưng khi bật tùy chọn này, ngôn ngữ sẽ được xác định theo từng dòng. Điều này cho phép các bài hát đa ngôn ngữ hoạt động NHƯNG ngôn ngữ có thể không phải lúc nào cũng chính xác (ví dụ: nếu lời bài hát tiếng Ukraine không chứa bất kỳ chữ cái nào đặc trưng của tiếng Ukraine, nó có thể được chuyển sang tiếng Nga).\n\nNếu bạn không gặp sự cố, bạn nên tắt tùy chọn này. La Mã hóa bài hát hiện tại Chọn từ thư viện Xoá ảnh bìa tùy chỉnh Cho phép giảm tải Sử dụng đường dẫn giảm tải để phát. Tắt tính năng này có thể làm tiêu tốn pin nhưng có thể hữu ích nếu bạn gặp sự cố khi phát hoặc xử lý hậu kỳ Đã tải lên Đã tải lên Tải tất cả các bài hát để phát ngoại tuyến Xóa tất cả các bài hát từ danh sách phát này Đang tiến hành tải xuống Chia sẻ danh sách phát này Xóa danh sách phát này vĩnh viễn Đồng bộ danh sách phát với YouTube Music Kiểu màu chính Kiểu màu tam cấp (phụ) Lướt bài hát để xóa khỏi danh sách phát Bật hiệu ứng phát sáng cho lời bài hát Thêm hiệu ứng làm nổi bật và hiệu ứng bounce (nảy) cho phần lời bài hát đang được chọn Bật Better Lyrics Lời bài hát khớp theo từng âm tiết cho mọi bài hát, dùng để hát karaoke Đồng bộ lại Hiển thị danh sách phát \"Đã tải lên\" Phát đảo trộn danh sách phát / Album Khi kích hoạt phát đảo trộn, nó sẽ phát tất cả các bài hát từ danh sách phát / Album trước, sau đó sẽ phát nội dung tương tự Hiển thị thẻ Wrapped Dùng chi tiết thay vì trạng thái Hiện tên bài hát chính thay vì tên nghệ sĩ Lời Romanize Chinese Trình cập nhật Tự động kiểm tra cập nhật Bật thông báo về cập nhật Đã có cập nhật mới Cập nhật ứng dụng Thông báo về phiên bản mới của ứng dụng Google Cast Bật trình chiếu Audio đến Chromecast và các thiết bị khác có hỗ trợ tính năng trình chiếu (Cast) Phiên âm chữ Ma-xê-đô-ni-a (Macedonia) sang chữ Latinh Tích hợp tiện ích Tên người dùng Mật khẩu Tích hợp Last.fm Giới thiệu nghệ sĩ Hiển thị thêm Hiện ít hơn Trang nghệ sĩ Hiển thị mô tả nghệ sĩ Hiện số lượng người đăng ký Hiển thị số lượt nghe hằng tháng Metrolist Logo 2025 Tham gia phòng Tạo phòng và chia sẽ mã cho bạn bè Mã phòng Bạn là chủ phòng Bạn là khách Tắt tiếng Bật tiếng Yêu cầu tham gia Xem nhật ký Ngắt kết nối Đang kết nối… Lỗi kết nối Tạo phòng Tên người dùng Đã kết nối Đang kết nối lại… URL máy chủ Chọn máy chủ Máy chủ tùy chỉnh Sử dụng máy chủ tùy chỉnh Cài đặt widget Không có bài hát đang phát Chạm để mở Metrolist Bài trước Phát/Tạm dừng Bài tiếp Thích Widget trình phát nhạc với các nút điều khiển Nghe cùng nhau Gỡ lỗi kết nối và tin nhắn Nhật ký kết nối Chưa có nhật ký nào Tự động duyệt yêu cầu tham gia Tự động duyệt yêu cầu tham gia thay vì xem xét thủ công Đồng bộ âm lượng của chủ phòng Khách sẽ theo mức âm lượng của chủ phòng Nghe nhạc cùng bạn bè theo thời gian thực. Tạo phòng để làm chủ phòng hoặc tham gia phòng có sẵn bằng mã. Lưu ý: Bạn có thể bị ngắt kết nối nếu tạo phòng khi chưa phát nhạc rồi chuyển sang ứng dụng khác. Nghe cùng nhau chưa được cấu hình. Vui lòng thiết lập URL máy chủ trong Cài đặt → Tích hợp → Nghe cùng nhau. %1$s đã yêu cầu %2$s Đã gửi gợi ý đến chủ phòng! %1$s muốn tham gia phòng Nghe cùng nhau Thông báo cho các sự kiện Nghe cùng nhau Phòng đã được tạo: %s Không thể chỉnh sửa tên người dùng khi đang ở trong phòng Đang chờ chủ phòng phê duyệt Mã phòng không hợp lệ Yêu cầu tham gia bị từ chối Tham gia phòng có sẵn Mã phòng Rời phòng Tham gia Tạo Đang tham gia phòng %s… Đang tạo phòng… Kết nối Ngắt kết nối Tạo Tham gia Duyệt Từ chối Xóa Sao chép Đã sao chép vào bộ nhớ tạm Chưa thiết lập Đang làm chủ phòng Trong phòng Yêu cầu đang chờ Gợi ý chờ duyệt Gợi ý cho chủ phòng Loại khỏi phòng Chủ phòng Bạn Người dùng đã kết nối Nhập tên người dùng Bắt buộc nhập tên người dùng. Đồng bộ lại Sao chép mã Loại người này khỏi phiên Chặn vĩnh viễn Chặn yêu cầu tham gia của người này và ẩn các gợi ý của họ Chuyển quyền sở hữu Chuyển người này làm chủ phòng Quản lý người dùng Người dùng bị chặn Đã chặn %d người dùng Không có người dùng bị chặn Bỏ chặn Người dùng bị chặn bởi chủ phòng Ứng dụng bị sập Đã xảy ra lỗi không mong muốn. Vui lòng chia sẻ báo cáo sự cố để giúp chúng tôi khắc phục lỗi. Chia sẻ nhật ký Chia sẻ báo cáo sự cố Báo cáo sự cố Metrolist Đóng Không có nhật ký sự cố Đỏ thẫm Hồng Tím Tím đậm Chàm Xanh dương Xanh da trời Xanh lục lam Xanh lam Xanh lá cây Xanh lá nhạt Xanh lá mạ Vàng Hổ phách Cam Cam đậm Nâu Xám Xanh xám Chế độ sáng Chế độ tối Chế độ hệ thống %1$s bảng màu Cắt ảnh bìa album Cắt ảnh thu nhỏ video về tỷ lệ vuông Bỏ qua đoạn im lặng trong bài hát Bỏ qua đoạn im lặng tức thì Tua nhanh qua các đoạn im lặng thay vì tăng tốc độ phát Giữ chế độ phát ngẫu nhiên Giữ chế độ ngẫu nhiên khi phát bài/playlist mới Nhớ trạng thái ngẫu nhiên và lặp Nhớ chế độ ngẫu nhiên & lặp khi mở lại ứng dụng Tạm dừng nhạc khi tắt tiếng media Giữ màn hình bật khi mở trình phát Đánh dấu yêu thích/bỏ yêu thích bài hát trên Last.fm khi chúng được thích/bỏ thích trong Metrolist Đang đăng nhập… Ẩn bài hát video Xem thông tin bài hát Sửa tên bài hát hoặc nghệ sĩ Không có Mờ dần Phát sáng Trượt Karaoke Apple Music Cỡ chữ lời bài hát Giãn dòng lời bài hát Lượn sóng Kiếm lời nhạc từ SimpMusic Tự động lấy lời bài hát từ Musixmatch và Bản ghi YouTube Độ trễ của lời Thêm vào đầu danh sách phát nhạc Thêm vào cuối danh sách phát nhạc Lưu vào thư viện Cho phép phát lại ngoại tuyến Lưu vào danh sách nhạc Lấy dữ liệu mới nhất từ Youtube Music Chia sẻ liên kết cho bài hát này Xóa vĩnh viễn bài hát này Đổi nhịp độ và cao độ của bài hát Chỉnh bộ cân bằng âm thanh Cho phép Instagram Lưu để nghe sau METROLIST GNU General Public License v3.0 Dựa trên %1$s Giữ lại Xoá bỏ Kênh Telegram Website GitHub Podcasts Xem podcast Không tìm thấy tài khoản Nhập mã phòng Cấu hình server, username, và thêm nữa AI dịch lời bài hát Đang dịch lời... Lời đã dịch xong Tĩnh Mật độ hiển thị Khởi động lại Khởi động lại được yêu cầu Thay đổi mật độ hiển thị sẽ có hiệu lực sau khi khởi động lại ứng dụng. Bạn có muốn khởi động lại ngay không? Cơ sở dữ liệu lời bài hát khớp thời gian do cộng đồng đóng góp Lời bài hát lấy từ KuGou, một nền tảng âm nhạc phổ biến của Trung Quốc LƯU Ý: Lời bài hát từ YouTube Music sẽ tự động được hiển thị khi không lời bài hát nào khác có sẵn. Lời bài hát từ YTM thường không đồng bộ. Bật LyricsPlus Đồng bộ lời bài hát từ nhiều nguồn Chọn bên cung cấp Chọn các nguồn cung cấp lời bài hát được kích hoạt Tránh lặp lại bài hát trong hàng Khi thêm bài hát vào hàng đợi, xóa nó ở vị trí trước đó nếu đã có sẵn Ưu tiên nhà cung cấp lời bài hát Kéo để xắp xếp lại nhà cung cấp theo ý muốn. Vị trí càng cao -> Uư tiên càng cao. Lịch sử cập nhật Không có lịch sử cập nhật có sẵn https://github.com/MetrolistGroup/Metrolist/releases Xem trong GitHub Phiên bản hiện tại Phiên bản: %s Cài đặt cập nhật Kiểm tra cập nhật Đang kiểm tra cập nhật… Mới nhất: %s Kiểm tra cập nhật Ẩn lịch sử cập nhật Hiện lịch sử cập nhật Kiểm tra cập nhật không thành công: %s Đặt làm mặc định Hẹn giờ ngủ mặc định đặt là %d phút Tiếp tục khi kết nối Bluetooth Đan xen nhạc Đan xen giữa các bài hát Thời lượng đan xen Tắt cho album gapless Không đan xen nếu album là gapless Tính năng Beta Đan xen là tính năng mới và có thể sẽ có lỗi. Nếu bạn gặp bất kì vấn đề gì, vui lòng báo cáo.\n\nTính năng này sẽ tắt Xử lý âm thanh độc lập vì giới hạn kĩ thuật. La-tinh hóa lời bài hát Hindi La-tinh hóa lời bài hát Punjabi Vô hiệu hóa vì Đan xen đang kích hoạt Hiện lời bài hát La-tinh hóa là chính Kích hoạt scrobbling Gửi Hiện Đang Phát Gửi Thích/Không thích Cấu hình Scrobbling Scrobble các bài hát dài hơn Tỉ lệ phần trăm chờ Scrobble Phút chờ Scrobble Ẩn YouTube Shorts Tạo đài phát từ nội dung này Kích hoạt biểu tượng động Trình phát mini Trình phát mini đen thuần Từ từ đã! Bạn đã chọn giới hạn kích cỡ bộ nhớ đệm nhỏ hơn hiện tại đang sử dụng (%1$s). Nếu bạn tiếp tục, ứng dụng sẽ xóa bớt một số bộ nhớ đệm %2$s để khớp giới hạn mới. Vẫn tiếp tục? Tiếp tục Phong cách hiệu ứng chạy từng từ Ảnh album cho %s Bạn đã nghe Album độc đáo Album nghe nhiều nhất là Danh sách phát cá nhân hóa của bạn đã sẵn sàng Top 5 album của bạn Bạn đã nghe album này được %d phút %d phút Không có dữ liệu Phần mềm miễn phí, mã nguồn mở. Bạn có thể sử dụng, học hỏi, chia sẻ và cải thiện nó. Máy chủ Discord Xem Repository %1$s • %2$s Thích những gì tôi làm? Ủng hộ tôi một ly cà phê Cộng đồng & Thông tin Dự án này đứng về Palestine 🇵🇸 Kênh Podcast Tập Mới Nhất Chương Trình Của Bạn Tập Mới Tập Để Sau Thêm vào danh sách phát Tập xem sau Đã xóa khỏi lưu Lưu podcast vào thư viện %d tập Tập Hồ sơ Kênh Danh sách phát tự động Tập đã tải về Chưa có kênh được đăng kí Chưa có tập được tải về %d kênh Phục hồi sao lưu? Cái này sẽ phục hồi dữ liệu ứng dụng của bạn từ bản sao lưu. Bạn sẽ phải đăng nhập sau khi phục hồi. Những tài khoản sau sẽ bị đăng xuất: Phục hồi Kiểm tra tài khoản trước đây… Kích hoạt hẹn giờ ngủ tự động ================================================ FILE: app/src/main/res/values-vi/strings.xml ================================================ Trang chủ Bài hát Nghệ sĩ Album Danh sách phát Đã chọn %d Lịch sử Thống kê Tâm trạng và thể loại Tài Khoản Lựa chọn nhanh Hãy nghe các bài hát để tạo danh sách chọn nhanh của bạn Các album mới phát hành Hôm nay Hôm qua Tuần này Tuần trước Bài hát được phát nhiều nhất Nghệ sĩ được phát nhiều nhất Album được phát nhiều nhất Tìm kiếm Tìm kiếm trên YouTube Music… Tìm kiếm trong thư viện… Thư viện Đã thích Đã tải xuống Tất cả Bài hát Video Album Nghệ sĩ Danh sách phát Danh sách phát cộng đồng Danh sách phát nổi bật Đã đánh dấu Không tìm thấy kết quả nào Từ thư viện của bạn Bài hát được yêu thích Bài hát được tải xuống Danh sách phát đang trống Thử lại Đài phát Trộn bài Cài lại Chi tiết Chỉnh sửa Bắt đầu đài phát Phát Phát bài tiếp theo Thêm vào hàng chờ Thêm vào thư viện Xoá khỏi thư viện Tải xuống Đang tải xuống Xoá mục đã tải xuống Nhập danh sách phát Thêm vào danh sách phát Xem nghệ sĩ Xem album Làm mới Chia sẻ Xoá Xoá khỏi lịch sử Tìm kiếm trực tuyến Đồng bộ Nâng cao Ngày thêm vào Tên Nghệ sĩ Năm Số lượng bài hát Độ dài Thời gian phát Tuỳ chỉnh bộ lọc ID phương tiện Định dạng Bộ giải mã Tốc độ bit Tỷ lệ mẫu Độ ồn Âm lượng Kích thước tệp tin Không rõ Đã sao chép vào clipboard Sửa lời bài hát Tìm kiếm lời bài hát Sửa bài hát Tên bài hát Nghệ sĩ bài hát Tên bài hát không được để trống. Nghệ sĩ bài hát không được để trống. Lưu Chọn danh sách phát Chỉnh sửa danh sách phát Tạo danh sách phát Tên danh sách phát Tên danh sách phát không được để trống. Chỉnh sửa nghệ sĩ Tên nghệ sĩ Tên nghệ sĩ không được để trống. %d bài hát %d nghệ sĩ %d album %d danh sách phát %d tuần %d tháng %d năm Danh sách phát đã được nhập vào Đã xoá \"%s\" khỏi danh sách phát Danh sách phát được đồng bộ Hoàn tác Không tìm thấy lời bài hát Hẹn giờ ngủ Kết thúc bài hát %d phút Không có luồng nào khả dụng Không có kết nối mạng Hết giờ Lỗi không xác định Thích Bỏ thích Bật trộn bài Tắt trộn bài Tắt lặp lại Lặp lại bài hát này Lặp lại hàng đợi Tất cả bài hát Những bài hát được tìm kiếm Trình phát nhạc Cài đặt Hiển thị Bật chủ đề theo màu sắc Chủ đề tối Bật Tắt Theo hệ thống Tối hoàn toàn Tab mặc định Tùy chỉnh thanh tab điều hướng Vị trí lời bài hát Trái Giữa Phải Nội dung Sự đăng nhập Nội dung ngôn ngữ mặc định Nội dung quốc gia mặc định Mặc định hệ thống Bật Proxy Loại Proxy Liên kết Proxy Khởi động lại để áp dụng Trình phát và âm thanh Chất lượng âm thanh Tự động Cao Thấp Cố định hàng đợi Bỏ qua khoảng lặng Chuẩn hoá âm lượng Bộ chỉnh âm Bộ nhớ Bộ nhớ đệm Bộ nhớ đệm hình ảnh Bộ nhớ đệm bài hát Kích thước bộ nhớ đệm tối đa Không giới hạn Xoá tất cả các mục đã tải xuống Kích thước bộ nhớ đệm hình ảnh tối đa Xóa bộ nhớ đệm hình ảnh Kích thước bộ nhớ đệm bài hát tối đa Xóa bộ nhớ đệm bài hát Đã sử dụng %s Quyền riêng tư Tạm dừng lịch sử nghe Xóa lịch sử nghe Bạn có chắc muốn xoá tất cả lịch sử nghe không? Tạm dừng lịch sử tìm kiếm Xóa lịch sử tìm kiếm Bạn có chắc muốn xoá tất cả lịch sử tìm kiếm? Bật nhà cung cấp lời bài hát KuGou Sao lưu và khôi phục Sao lưu Khôi phục Đã nhập danh sách phát Đã tạo bản sao lưu thành công Không thể tạo bản sao lưu Không thể khôi phục bản sao lưu Giới thiệu Phiên bản ứng dụng Phiên bản mới có sẵn Mô hình dịch thuật Xoá mô hình dịch thuật Xoá khỏi danh sách phát Bản sao Bỏ qua các bản sao Vẫn cứ thêm vào Bài hát đã có trong danh sách phát của bạn %d bài hát đã có trong danh sách phát của bạn Bạn có thực sự muốn xóa tất cả \"%s\" bài hát trong danh sách phát khỏi bộ nhớ Bài hát được tải xuống không? Bạn có thực sự muốn xóa \"%s\" danh sách phát không? Chưa đăng nhập Bật nhà cung cấp lời bài hát LrcLib Tích hợp Discord Bỏ qua Tùy chọn Xem trước Đăng nhập không thành công Đăng xuất Kích hoạt Rich Presence Cạnh bên Căn chỉnh văn bản của trình phát Ẩn nội dung phản cảm Những mục yêu thích bị lãng quên Tiếp tục nghe Bài hát trong thư viện sẽ hiển thị ở đây Nghệ sĩ thư viện sẽ xuất hiện ở đây Album thư viện sẽ hiển thị ở đây Danh sách phát của bạn sẽ hiển thị ở đây Các phiên bản khác Nhịp độ và Cao độ Trình phát Kiểu thanh trượt của trình phát Mặc định Lượn sóng Kích thước ô lưới Nhỏ Khác Lớn Khôi phục hàng đợi cuối cùng của bạn khi ứng dụng bắt đầu Hàng đợi Tự động tải thêm bài hát Dừng nhạc khi đóng ứng dụng Vô hiệu ảnh chụp màn hình Tự động chuyển sang bài hát tiếp theo khi xảy ra lỗi Danh sách phát YouTube của bạn Tương tự Tự động thêm nhiều bài hát hơn khi hàng đợi kết thúc, nếu có thể Thêm tất cả vào thư viện Xóa tất cả khỏi thư viện Xóa khỏi hàng đợi Xóa tất cả lượt thích Thích tất cả Lịch sử nghe Chủ đề Đảm bảo trải nghiệm phát lại liên tục của bạn Lịch sử tìm kiếm Khi tùy chọn này được bật, ảnh chụp màn hình và chế độ xem ứng dụng trong mục Gần đây sẽ bị vô hiệu hóa. Metrolist sử dụng thư viện KizzyRPC để thiết lập trạng thái tài khoản Discord của bạn. Điều này liên quan đến việc sử dụng kết nối Discord Gateway, có thể được coi là vi phạm TOS của Discord. Tuy nhiên, không có trường hợp nào được biết đến về việc tài khoản người dùng bị đình chỉ vì lý do này. Sử dụng theo rủi ro của riêng bạn. \n \nMetrolist sẽ chỉ trích xuất mã thông báo của bạn và mọi thứ khác được lưu trữ cục bộ. Đăng nhập để xem nội dung Điều này có thể ảnh hưởng đến nội dung bạn nhìn thấy, ví dụ như hiện những album dành riêng cho premium nếu bạn đăng nhập với tài khoản Premium Đăng nhập ================================================ FILE: app/src/main/res/values-wae/metrolist_strings.xml ================================================ ================================================ FILE: app/src/main/res/values-zh-rCN/metrolist_strings.xml ================================================ 本地 远程 排行榜 返回 专辑封面 热门音乐视频 热门 已点赞 已下载 我的最爱 已缓存 同步播放列表 生成图片 请稍候 取消 以文本形式分享 以图片形式分享 最大选择限制 分享已选内容 自定义颜色 文本颜色 次要文本颜色 背景颜色 从缓存中删除 复制链接 全选 全部点赞 链接已复制到剪贴板 歌词 已在播放列表中: %d 次 相似内容 播放器背景样式 跟随主题 渐变 模糊 播放器按钮颜色 默认 启用滑动手势切换歌曲 向左滑动以添加到待播列表,向右滑动歌曲以下一首播放 纤细 更细的底部导航栏 显示“已缓存”播放列表 使用令牌登录 点击显示令牌 再次点击以复制或编辑 这是高级登录方法。作为网页门户的替代方案,您可以直接在此处输入或更新您的登录令牌。例如,这可加快在多台设备上的登录速度。请注意,应用无法解析的任何无效令牌格式均不会被接受 常规 代理 设置猜你喜欢 基于上次听的歌曲 启用相似内容 在待播列表快结束时,自动添加更多相似歌曲 %d%% 应用语言 自动下载已点赞的歌曲 当你点赞某首歌曲时,系统会自动为你下载该歌曲 您确定要清除所有缓存的歌曲吗? 您确定要清除所有下载内容吗? 未登录 YouTube 打开支持的链接 发行说明 所有时间 过去一周 过去一个月 过去一年 我的热门列表长度 描述 %d 秒 连续 注意:此功能可与 YouTube Music 进行同步。此设置后续无法更改。 同步已禁用 分享歌词 浏览量 显示“已下载”播放列表 过去 24 小时 显示“我的最爱”播放列表 无法打开应用设置 信息 倒赞所有 更新日期 点击更换歌词 自动播放列表 显示“已点赞”播放列表 更改默认库标签 历史持续长度 点赞 倒赞 正在播放 新的播放器外观 新的播放控件外观 歌词自动滚动 使用日文(罗马音) 使用韩文(罗马音) 自动与账号同步 更多内容 导入 M3U 格式的播放列表 导入 CSV 格式的播放列表 注意:不支持将本地歌曲添加到同步/远程播放列表,其他组合均有效 播放控件灵敏度 您确定要清除所有缓存的图片吗? 禁用 订阅 已订阅 %1$d%% 关闭 隐藏专辑封面 在播放器中将专辑封面替换为应用图标 快进+%1$d 秒 快退-%1$d 秒 渐进式快进/快退 如果循环播放则停止加载更多歌曲 循环播放模式启用后,不会自动加载更多歌曲与相似内容 用户界面 安全与隐私 播放器与内容 储存与数据 系统与关于 启用后,每次快进/快退都额外增加5秒 正在打开电台 配置代理 代理用户名 代理密码 启用身份验证 罗马音设置 罗马化 歌词罗马化 使用俄文(罗马音) 使用乌克兰语(罗马音) 使用白俄罗斯语(罗马音) 使用柯尔克孜语(罗马音) 使用塞尔维亚语(罗马音) 使用保加利亚语(罗马音) 实验性:逐行检测语言 罗马音将会按行检测,而不是检测整首歌曲。 您确定吗? 这是一个不稳定的实验性功能。\n\n默认情况下,语言会根据整首歌曲来判断,但启用该选项后,将会按行检测语言。这样可以支持多语言歌曲,但检测出的语言可能并非总是正确(例如,如果一行乌克兰语歌词没有包含任何乌克兰语特有字母,可能会被识别并罗马化为俄语)。\n\n如果你没有遇到相关问题,建议保持该选项关闭。 罗马化当前曲目 编辑播放列表封面 提示:您的 YouTube Music 账号必须绑定电话号码并通过验证才能更改播放列表封面。 选择图片后,请等待片刻,新封面就会出现在您的播放列表中。 从库中选择 删除自定义图片 已上传 已上传 显示“已上传”播放列表 使用详细信息代替状态 突出显示歌曲标题,而非音乐人名称 更新器 自动检查更新 启用更新通知 有可用更新 新版本通知 开启省电播放模式。关闭后可能会增加耗电量,如果您遇到音频播放或后处理相关的问题,关闭它可能会有所帮助 应用更新 开启省电播放 集成 用户名 密码 Last.fm 集成 使用马其顿语(罗马音) 启用记录 发送正在播放的歌曲 Scrobbling记录设置 记录比…长的歌曲 记录所需已听时间(分钟) 记录所需已听占比 滑动歌曲将其从播放列表中删除 下载所有歌曲以离线播放 从此播放列表中删除所有已下载的歌曲 下载正在进行 分享此播放列表 永久删除此播放列表 将播放列表与 YouTube Music 同步 主色 第三色 启用歌词发光效果 为动态歌词添加发光动画和弹跳效果 启用 Better Lyrics 提供歌词 支持任意歌曲的逐音节同步歌词,适用于卡拉OK 对齐歌词 随机播放列表或专辑 随机播放时,先播放原播放列表/专辑中的所有歌曲,然后再播放内容相似的歌曲 显示年度总结卡片 使用中文(罗马音) Google Cast 启用向 Chromecast 和其他支持投屏功能的设备投屏音频 发送点赞/取消点赞 Last.fm上的歌曲在Metrolist上被点赞/取消点赞时,也会被标记为喜欢/不喜欢 正在登录… 隐藏带视频歌曲 查看歌曲信息 更改标题或音乐人 基于此歌曲创建一个电台 添加到待播列表顶部 添加到待播列表底部 保存到您的媒体库 提供离线播放功能 添加到您的播放列表 从 YouTube Music 获取最新元数据 展示此歌曲的链接 完全移除此歌曲 改变歌曲的节奏和音调 调整音频均衡器 开启动态图标 迷你播放器 纯黑迷你播放器 且慢! 您选择的缓存大小限制小于应用当前使用的大小 (%1$s)。如果继续,应用可能会删除一些缓存的 %2$s 以匹配新的限制。是否仍然继续? 继续 逐字动画风格 褪色 生长 滑动 卡拉OK Apple Music 歌词文字大小 歌词行间距 %s 的专辑封面 你已经听过了 独特的专辑 您最喜欢的专辑是 您的个人播放列表已准备就绪 您最喜欢的五张专辑是 您已收听此专辑 %d 分钟 %d 分钟 没有数据 您今年最喜欢的音乐人 %d 分钟 您今年最喜欢的歌曲 专辑封面 您今年心中的最佳音乐人是 最喜欢的音乐人图片 您听了他们%d分钟 您播放次数最多的歌曲是 您已经听了%d分钟 您听了 独特的音乐人 您听了 独特的歌曲 METROLIST 是时候看看您最近听了什么了 让我们开始吧! Metrolist 徽标 2025 您的年度总结已准备好! 是时候看看您今年最喜欢什么了. 谢谢您的聆听 特别感谢 MO Agamy 创建了 Metrolist 关闭年度总结 您的 %s 年度总结 创建播放列表 播放列表已保存 转换为 %s 进度 %s%% 收听 Metrolist 打开 无法创建图片:%s 已复制标题 已复制音乐人 播放错误 无法解析代理网址。 %d 个配置文件 均衡器 无均衡器配置文件 导入配置 已禁用 删除配置 您确定要删除 %1$s 吗?此操作无法撤销。 无法读取文件 无法打开文件:%1$s 导入错误 %d 乐队 波浪 在静音时暂停播放 启用 SimpMusic 提供歌词 自动从 Musixmatch 和 YouTube Transcript 获取歌词 系统均衡器 专辑封面 没有音乐正在播放 点击打开 Metrolist 上一曲 播放/暂停 下一曲 喜欢 有播放控制的音乐播放器微件 带有播放和点赞控制按钮的播放控件 关于 展开 收起 音乐人页面 显示音乐人简介 显示订阅者数量 每月听众人数 快进跳过歌曲的静音部分 立即跳过静音 在静音片段快进而非加速播放 记住随机与循环设置 重启应用时,记住随机播放和重复播放模式 歌词偏移 持续随机播放 开始播放新歌曲或播放列表时,保持随机播放功能开启 播放失败 无法应用均衡器配置:%1$s 错误 裁剪专辑封面 通过裁剪视频缩略图强制使用正方形宽高比 当播放页面最大化时保持屏幕打开 一起听 服务器网址 用户名 已连接 正在重新连接… 已断开连接 正在连接… 连接错误 创建房间 创建房间并与朋友分享邀请码 加入房间 房间邀请码 您是房主 您是访客 加入请求 查看日志 调试连接和消息 连接日志 还没有日志 跨越距离,与好友实时共赏音乐。创建房间成为房主,或使用邀请码加入现有房间。 注意:如果在房间内没有播放音乐的情况下创建房间,然后切换到其他应用程序,则可能会断开连接. 一起听尚未配置。请在“设置”→“集成”→“一起听”中设置服务器网址。 %1$s 请求 %2$s 推荐已发送给房主! %1$s 人想要加入房间 一起听 “一起听”活动通知 房间创建成功 编号:%s 在房间内无法编辑用户名 等待房主批准 无效的房间邀请码 加入请求被拒绝 加入现有房间 房间邀请码 离开房间 加入 创建 正在加入房间 %s… 正在创建房间… 连接 断开连接 创建 加入 批准 拒绝 清除 复制 已复制到剪贴板 未设置 正主持房间 在房间内 待处理请求 待处理推荐 推荐给房主 踢出 房主 连接的用户 输入用户名 需要用户名。 重新同步 静音 取消静音 应用崩溃了 分享日志 发生意外错误。请提供崩溃报告以帮助我们修复问题。 分享崩溃报告 Metrolist 崩溃报告 关闭 没有可用的崩溃日志 灵动 赤红 玫瑰 紫色 深紫 靛青 蓝色 天蓝 青色 绿色 蓝绿 亮绿 柠檬 黄色 琥珀 橙色 深橙 棕色 灰色 返回 蓝灰 深色模式 系统模式 纯黑模式 浅色模式 样式 %1$s 选择服务器 自定义服务器 使用自定义服务器 自动批准加入请求 自动批准加入请求,无需手动审核 复制邀请码 管理用户 永久屏蔽 屏蔽此人的加入请求并隐藏其推荐 已屏蔽用户 已屏蔽 %d 位用户 无已屏蔽的用户 取消屏蔽 未在播放歌曲 点击打开 Metrolist 播放器 与房主音量保持一致 访客音量随房主同步 从此会话中移除该人 将此人设为房主 你已被房主拉黑 移交所有权 虚拟打碟台 输入邀请码 配置服务器、用户名等 AI歌词翻译 正在翻译歌词… 歌词已翻译 提供商 基础URL API 密钥 模型 翻译模式 目标语言 API 凭据 翻译 需要 API 密钥 需要 API 密钥 没有可翻译的歌词 无歌词 请选择目标语言 翻译出现意外错误 发生未知错误 翻译失败 播放全部 一起听 转录 识别音乐 YouTube 网址列(可选) 重听 您确定要清除所有识别历史吗? 未找到匹配 从历史中删除 音乐人名称列 正在识别… 清除识别历史 映射 CSV 列 列 %d 识别错误 强制屏幕以最高支持的刷新率运行(例如 120Hz) 首行是标题 重试 点击识别 识别历史 启用高刷新率 歌曲标题列 最近转换 正在导入 CSV 在 Metrolist 上播放 正在听… 继续 启用 淡入淡出 歌曲间淡入淡出 淡入淡出时长 对无缝专辑禁用 如果专辑为无缝格式则不淡入淡出 测试功能 淡入淡出是一项新功能,可能存在错误。如果您遇到任何问题,请及时反馈。\n\n由于技术限制,此功能会禁用省电播放。 因淡入淡出已启用而禁用 隐藏 YouTube Shorts 短视频 在顶部栏显示“一起听” 在顶部应用栏显示“一起听”,而非在导航栏显示 防止待播列表中出现重复曲目 将曲目添加到待播列表时,如果曲目已存在待播列表中,则将其从先前的位置移除 将含义翻译成目标语言 将发音转换为目标文字 获取 API 密钥 访问 https://openrouter.ai 获取免费和付费模型 访问 https://platform.openai.com/api-keys 访问 https://console.anthropic.com/settings/keys 访问 https://aistudio.google.com/apikey 访问 https://perplexity.ai/settings/api 访问 https://console.x.ai 访问 https://deepl.com/pro-api 获取免费和付费密钥 语气功能 默认 更正式一些 不太正式 状态 在线 空闲 请勿打扰 按钮 按钮 1 按钮 2 登录成功! 此功能使用 KizzyRPC 库连接到 Discord 网关并设置您的 Rich Presence 状态。虽然目前尚未发现因类似使用而导致账号被封禁的情况,但此方法并未获得 Discord 官方支持,并且可能会被视为违反服务条款。您的令牌将在本地提取,绝不会发送至第三方服务器。请自行承担使用风险。 活动类型 正在播放 正在听 正在看 正在竞争 变量:{song_name},{artist_name},{album_name} Rich Presence 预览 在场 使用 Discord 登录来分享你正在收听的内容 播放 Metrolist 观看Metrolist 参与Metrolist评选 活动名称 自定义活动名称(留空则使用默认值) 高级模式 显示 Rich Presence 的更多自定义选项 蓝牙连接后恢复播放 使用印地语(罗马音) 使用旁遮普语(罗马音) 将罗马化歌词设为主要显示 纯色 显示密度 重启 需要重启 显示密度更改将在重启应用后生效。是否现在要重启? 社区驱动的同步歌词数据库 歌词来自中国热门音乐平台酷狗 注意:当其他歌词不可用时,会自动显示来自 YouTube Music 的歌词,该来源歌词通常不同步。 启用 LyricsPlus 提供歌词 多源同步歌词支持 提供商选择 选择要启用的歌词提供商 歌词提供商优先级 拖动以按偏好重新排序提供商。位置越高 -> 优先级越高。 更新日志 暂无更新日志 https://github.com/MetrolistGroup/Metrolist/releases 在 GitHub 上查看 当前版本 版本:%s 更新设置 检查更新 检查更新 正在检查更新… 最新版本:%s 隐藏更新日志 查看更新日志 无法检查更新:%s 设为默认 睡眠定时器默认设置为 %d 分钟 可在“设置”>“内容”中找到 无法保存分集 无法移除分集 无法订阅播客 无法取消订阅播客 音乐识别器 直接从主屏幕识别周围播放的歌曲 点击识别歌曲 正在听… 正在识别… 未找到匹配,请重试 识别失败 发生错误,请重试 未知歌曲 未知音乐人 识别歌曲 音乐识别 在微件中识别歌曲时显示通知 正在录音以识别歌曲… 正在导入播放列表 Instagram GitHub %1$s • %2$s 查看仓库 保留媒体库数据? 是否要保留您的播放列表和媒体库数据?已下载的歌曲将始终保留。 保留 清除 首席开发者 协作者 协作者 GNU General Public License v3.0 自由开源软件。您可以使用、研究、分享和改进它。 Discord 服务器 Telegram 频道 网站 喜欢我的作品? 请我喝杯咖啡 社区与信息 METROLIST 想播放他们最喜欢的歌曲吗? 是的 本项目声援巴勒斯坦 🇵🇸 播客 查看播客 播客频道 最新分集 您的节目 新分集 待播分集 保存以稍后收听 添加到“待播分集”播放列表 从已保存中移除 将播客保存到媒体库 %d 个分集 恢复备份? 这将从备份中恢复您的应用数据。 恢复后您需要重新登录。以下账号将退出登录: 恢复 正在检查先前账号… 未找到账号 自动批准歌曲推荐 自动批准访客的歌曲推荐并加入待播列表 播放量 快速访问 固定到快速访问 从快速访问取消固定 随机排列首页顺序 根据优先级随机重新排列首页板块 %1$s 相似风格 因您收听 %1$s %1$s 相似内容 基于 %1$s %1$s 粉丝专享 来自社区 分集 频道 自动播放列表 已下载的分集 无已订阅的频道 无已下载的分集 %d 个频道 查看频道 个人资料 启用自动睡眠定时器 通过自定义时间自动启用默认值的睡眠定时器 设置睡眠定时器应自动激活的自定义日期和时间 重复 每日 周一至周五 工作日/周末 周末(周六至周日) 自定义 开始时间 结束时间 周一 周二 周三 周四 周五 周六 周日 定时器结束时,当前歌曲播放完毕后停止 在最后一分钟淡出 上传歌曲 正在上传… %1$d / %2$d 上传完成 上传失败 文件太大(最大 300MB) 不支持的格式。请使用 mp3、m4a、wma、flac 或 ogg 格式 删除已上传的歌曲 您确定要删除这首已上传的歌曲吗?此操作无法撤销。 您确定要删除 %1$d 首已上传的歌曲吗?此操作无法撤销。 上传的歌曲已删除 无法删除上传的歌曲 删除上传的歌曲 已删除 %1$d 首歌曲 正在删除… 导出播放列表 导出为 CSV 导出为 M3U 播放列表导出成功 无法导出播放列表 分享 保存到文档 识别音乐 ================================================ FILE: app/src/main/res/values-zh-rCN/strings.xml ================================================ 首页 歌曲 音乐人 专辑 播放列表 已选择 %d 项 历史 统计 曲调和流派 账号 歌曲快选 听一些歌曲来生成歌曲快选 新专辑 今天 昨天 本周 上周 最常播放的歌曲 最常播放的音乐人 最常播放的专辑 搜索 搜索 YouTube Music… 搜索媒体库… 媒体库 喜欢 已下载 全部 歌曲 视频 专辑 音乐人 播放列表 社区播放列表 精选播放列表 收藏 未找到结果 来自您的媒体库 喜欢的歌曲 已下载的歌曲 播放列表为空 重试 电台 随机播放 重置 详情 编辑 收听电台 播放 接下来播放 加入播放队列 加入媒体库 从媒体库中移除 下载 正在下载 移除下载 导入播放列表 加入播放列表 浏览音乐人 浏览专辑 刷新 分享 删除 从历史中移除 在线搜索 同步 高级 添加日期 名称 音乐人 年份 歌曲总数 长度 播放时间 自定义顺序 媒体 ID MIME 类型 编码 比特率 采样率 响度 音量 文件大小 未知 已复制到剪贴板 编辑歌词 搜索歌词 编辑歌曲 歌名 音乐人 歌名不能为空。 音乐人不能为空。 保存 选择播放列表 编辑播放列表 新建播放列表 名称 播放列表名称不能为空。 编辑音乐人 音乐人名称 音乐人名称不能为空。 %d 首歌曲 %d 位音乐人 %d 张专辑 %d 个播放列表 %d 周 %d 月 %d 年 播放列表已导入 已从播放列表中移除“%s” 播放列表已同步 撤销 未找到歌词 睡眠定时器 这首歌曲播放完毕 %d 分钟 没有可用音源 没有网络连接 连接超时 未知错误 喜欢 取消喜欢 随机播放打开 随机播放关闭 循环播放关闭 循环播放当前歌曲 循环播放队列 全部歌曲 搜索的歌曲 音乐播放器 设置 外观 启用动态主题 深色主题 跟随系统 纯黑 默认启动标签页 自定义导航标签页 歌词文本位置 靠左 居中 靠右 内容 登录 默认内容语言 默认内容国家/地区 系统默认 启用代理 代理类型 代理 URL 重启以应用变更 播放器与音频 音质 自动 保留播放队列 跳过无声片段 标准化音量 均衡器 储存 缓存 图像缓存 歌曲缓存 最大缓存大小 无限制 清除所有下载 图像缓存大小 清除图像缓存 歌曲缓存大小 清除歌曲缓存 已使用 %s 隐私 暂停听歌历史 清除听歌历史 是否确定要清除所有听歌历史? 暂停搜索历史 清除搜索历史 是否确定要清除所有搜索历史? 使用酷狗音乐提供歌词 备份与还原 备份 还原 已导入的播放列表 成功新建备份 无法新建备份 无法还原备份 关于 应用版本 有新版本 翻译模型 清除翻译模型 是否确定要删除播放列表“%s”? 是否确定要从已下载的歌曲存储中移除所有“%s”播放列表歌曲? 您的 YouTube 播放列表 其他版本 媒体库音乐人将显示在此处 媒体库专辑将显示在此处 您的播放列表将显示在此处 仍要添加 %d 首歌曲已在您的播放列表中 移除全部喜欢 主题 播放器 播放器文本对齐 默认 杂项 网格大小 未登录 播放队列 隐藏不适宜内容 使用 LrcLib 提供歌词 选项 预览 登录失败 登出 Discord 集成 启用 Rich Presence 喜欢全部 媒体库歌曲将显示在此处 这首歌曲已在您的播放列表中 关闭 播放器滑块样式 继续听 全部加入媒体库 全部从媒体库中移除 从播放列表中移除 节奏和音调 重复项 跳过重复项 靠边 波浪 听歌历史 搜索历史 禁用截屏 启用此选项后, 您无法截屏,也无法在“最近用过”中看到此应用的内容。 从播放队列中移除 确保您的连续播放体验 重温最爱 类似风格 应用启动时还原上次的播放队列 自动加载更多歌曲 如果可能,在播放队列快结束时自动添加更多歌曲 发生错误时自动跳到下一首歌曲 任务清除时停止音乐 Metrolist 使用 KizzyRPC 库来设置您的 Discord 账号的状态。这会用到 Discord 网关连接,可能会违反 Discord 的服务条款。不过,目前还没有因此原因暂停用户账号的情况。使用风险自负。 \n \nMetrolist 只提取您的令牌,其他内容都存储在本地。 这可能会影响您看到的内容,例如,如果您使用会员账号登录,则会显示仅会员专辑 登录账号浏览内容 登录 ================================================ FILE: app/src/main/res/values-zh-rTW/metrolist_strings.xml ================================================ 本地 雲端 連續 喜歡的歌曲 已下載 我的Top 同步播放清單 注意:這允許與 Youtube Music 同步,之後無法更改。 複製連結 全選 全部喜歡 全部不喜歡 上次更新 連結已複製至剪貼簿 歌詞 已在播放清單中: %d 次 相似內容 播放器背景樣式 跟隨主題 漸層 模糊 播放器按鈕顏色 預設 啟用滑動縮圖切換歌曲 點擊以切換歌詞 隱藏底部導覽列標籤 透過 token 登入 點擊以顯示 token 再次點擊以複製或編輯 這是作為網頁登錄的替代方案的進階登入模式一。你可以在此直接輸入或更新您的登入權杖。這可以用於加速在多台裝置上的登入流程。請注意,若權杖格式無效導致程式無法解析,權杖將不予採用 一般 Proxy 更改預設媒體庫標籤 設定快速選取 根據上次聆聽的歌曲 應用程式語言 啟用相似內容 當播放佇列快結束時,自動加入更多相似歌曲 %d%% 開啟支援的連結 無法開啟應用設定 更新記錄 所有時間 過去 24 小時 過去一週 過去一個月 過去一年 我的熱門清單長度 加入歷史紀錄的播放時長 %d 秒 自動滾動歌詞 顯示「喜歡的歌曲」播放清單 顯示「已下載」播放清單 顯示「快取」播放清單 匯入「csv」播放清單 匯入「m3u」播放清單 Note: 不支援新增本地歌曲到同步/雲端播放清單。任何其他組合才有效 顯示「Top」播放清單 排行榜 返回 專輯封面 熱門音樂影片 發燒 快取 關閉同步 生成圖片 請稍候 取消 分享歌詞 以文字分享 以圖片分享 最大選擇上限 分享已選項目 自訂顏色 次要文字顏色 背景顏色 文字顏色 從快取中移除 自動生成播放清單 向左滑動歌曲加入播放佇列,或向右滑動加入下首播放 自動下載喜歡的歌曲 當你點擊喜歡某首歌曲時,將自動下載該歌曲 確定要清除所有快取歌曲嗎? 確定要清除所有下載內容嗎? 未登入 YouTube 說明 播放次數 喜歡 資訊 不喜歡 新播放器設計 羅馬化日文歌詞 羅馬化韓文歌詞 自動與帳號同步 更多內容 迷你播放器滑動靈敏度 %1$d%% 確定要清除所有快取的圖片嗎? 禁用 訂閱 已訂閱 電台開始中 現在播放 關閉 隱藏播放器封面 播放器中以應用程式圖像代替歌曲封面 前進%1$d秒 後退%1$d秒 漸進式時間跳躍 啟用此選項後,每次快進轉或倒轉時將遞增 5 秒的跳轉長度 使用全新迷你播放器設計 更改播放清單圖片 注意:你的帳戶必須連結到電話號碼並在 YouTube Music 上進行驗證才能更改播放清單封面。 選擇圖片後,請等待片刻,新的封面就會出現在你的播放清單中。 設定代理伺服器 代理伺服器名稱 代理伺服器密碼 開啟身份驗證 開啟「重複全部」時停用「載入更多」 啟用「重複播放」模式時,停用「自動載入」更多歌曲和類似內容 西里爾 羅馬化 羅馬化歌詞 羅馬化俄文歌詞 羅馬化烏克蘭文歌詞 羅馬化白俄羅斯文歌詞 羅馬化吉爾吉斯語歌詞 羅馬化塞爾維亞語歌詞 羅馬化白俄羅斯文歌詞 實驗性:逐行偵測語言 西里爾語將逐行偵測,而不是整首歌曲。 確定嗎? 這是一項不保證穩定性的實驗性功能。\n\n在預設情況下,系統會根據整首歌來判斷語言;但開啟此選項後,將改為逐行判斷。這可以支援多語言歌曲,但判斷結果未必完全準確(例如:如果一段烏克蘭語歌詞中不含該語言特有的字母,系統可能會將其誤判並以俄語羅馬化呈現)。\n\n如果目前使用沒有問題,建議維持關閉狀態。 將當前曲目羅馬化 介面 私隱與安全性 播放器和內容 儲存和數據 系統及關於 已上傳 可用更新 滑動歌曲即可將其從播放清單刪除 顯示「已上傳」播放清單 已上傳 從媒體庫選擇 移除自定義圖片 使用詳細內容而非狀態 強化顯示曲名而非作曲家 更新器 自動檢查更新 開啟更新通知 應用程式更新 新版本更新通知 啟動音訊卸載 使用卸載音訊路徑進行播放。停用此功能可能會增加耗電量,但若遇到音訊播放或後處理問題,建議將其關閉 羅馬化馬其頓語歌詞 整合 使用者名稱 密碼 Last.fm 整合功能 開啟紀錄播放歌曲 傳送正在播放歌曲 傳送喜歡/不喜歡歌曲 同步 Metrolist 喜歡/不喜歡歌曲到 Last.fm 歌曲紀錄設定 紀錄播放長度超過以下時間的歌曲 紀錄延遲比例 紀錄延遲時間 下載所有歌曲以供離線播放 從此播放列表中刪除所有已下載的歌曲 下載正在進行 與他人分享此播放列表 永久刪除此播放列表 將播放列表與YouTube Music同步 主色調 三級顏色 啟用歌詞發光效果 為動態歌詞添加發光動畫與彈跳效果 啟用 Better Lyrics 適用於任何歌曲的音節同步歌詞,可用於卡拉OK 重新同步 隨機播放清單/專輯 隨機播放時,先播放原播放清單/專輯中的所有歌曲,然後再播放內容相似的歌曲 展示「年度播放」卡片 中文歌詞羅馬化 啟用向 Chromecast 和其他支援投影功能的裝置投屏音頻 正在登錄… 隱藏帶影片的音樂 瀏覽歌曲信息 更改標題或藝術家 基於此歌曲創造一個電台 添加到列表頂部 顯示更多 顯示更少 關於 藝人頁面 顯示藝人的資訊 顯示訂閱人數 顯示每月聽眾人數 設定為可離線播放 新增至媒體庫 永久刪除此項目 匯入時發生錯誤 複製藝人 撥放時發生錯誤 無法解析 proxy 喜歡 卡拉 OK Google Cast 無法創建影像:%s 複製標題 匯入設定檔 %d 設定檔 等化器 無等化器設定檔 系統等化器 關閉 繼續 刪除設定檔 確定要刪除 %1$s 嗎?刪除後將無法恢復 Wavy 啟用動態 icon 啟用 SimpMusic 歌詞 使用 SimpMusic 以同步歌詞 跳過靜音片段 快轉歌曲中的靜音片段 在靜音片段時直接跳轉,而非加速快轉 調整等化器 開啟檔案時發生錯誤:%1$s 進度 %s%% 開啟 淡出 無法閱讀檔案 高亮效果 滑動 靜音時暫停音樂播放 快速存取最近播放的曲目 點擊開啟 Metrolist 在開始播放新歌曲或播放清單時,仍維持隨機播放狀態 固定隨機播放 啟用 裁切專輯封面 透過裁切影片縮圖強制使用正方形寬高比 純色 顯示密度 重新啟動 需要重啟 顯示密度變更將在重新啟動應用後生效。您現在要重啟嗎? 社群驅動的同步歌詞資料庫 歌詞來自中國最大的線上音樂平台 注意:當其他歌詞無法使用時,會自動顯示來自 YouTube Music 的歌詞,來源歌詞通常不同步。 將曲目新增至佇列時,如果該曲目已存在於佇列中,則將其從先前的位置移除 歌詞提供者優先權 拖曳即可依優先順序重新排列供應商。位置越高,優先權越高。 記住隨機與循環設置 重啟應用程式時,請記住隨機播放和重複播放模式 匯出播放列表 匯出為 CSV 文件 導出為 M3U 播放清單已成功匯出 匯出播放清單失敗 分享 儲存到文檔 更新日誌 暫無更新日誌 在 GitHub 上查看 目前版本 更新設定 請檢查更新 正在檢查更新… 請檢查更新 隱藏更新日誌 查看更新日誌 設定為預設值 啟用自動睡眠定時器 透過自訂時間自動啟用預設值的睡眠定時器 設定睡眠定時器應自動啟動的自訂日期和時間 重複 每天 週一至週五 平日/週末 週末(週六到週日) 自訂 開始時間 結束時間 週一 週二 週三 週四 星期五 週六 星期日 當計時器結束時,在當前歌曲結束時停止播放 最後幾分鐘淡出 藍牙連線恢復 播放器最大化時保持螢幕常亮 歌曲淡入淡出 歌曲之間的淡入淡出 ================================================ FILE: app/src/main/res/values-zh-rTW/strings.xml ================================================ 首頁 歌曲 藝人 專輯 播放清單 已選取 %d 個項目 歷史記錄 統計 情境與類型 帳號 歌曲快選 聽一些音樂讓我們知道您的喜好 重溫舊愛 再聽一次 你的 YouTube 播放清單 風格近似 新專輯 今天 昨天 這週 上週 最常播放的歌曲 最常播放的藝人 最常播放的專輯 搜尋 搜尋 YouTube Music… 搜尋媒體庫… 媒體庫 已按讚 已下載 全部 歌曲 影片 專輯 藝人 播放清單 社群播放清單 精選播放清單 收藏 找不到結果 媒體庫的歌曲會顯示在這裡 媒體庫的藝人會顯示在這裡 媒體庫的專輯會顯示在這裡 你的播放清單會顯示在這裡 來自你的媒體庫 其他版本 喜歡的歌曲 已下載的歌曲 播放清單為空 確定要刪除「%s」的下載嗎? 確定要刪除播放清單「%s」嗎? 重試 電台 隨機播放 重設 詳細資訊 編輯 開啟電台 播放 接著播放 加入待播清單 加入媒體庫 全部加入媒體庫 從音樂庫中移除 全部從音樂庫中移除 下載 下載中 刪除下載 匯入播放清單 加入播放清單 瀏覽藝人 瀏覽專輯 更新資料 分享 移除 從記錄中移除 從播放清單中移除 從播放佇列中移除 線上搜尋 同步 進階 速度和音調 新增時間 名稱 藝人 年份 歌曲總數 長度 播放時間 自訂順序 Id MIME 類型 編碼 位元速率 採樣率 響度 音量 檔案大小 未知 已複製至剪貼簿 編輯歌詞 搜尋歌詞 編輯歌曲 歌名 藝人 歌名不能為空 藝人不能為空 儲存 選擇播放清單 編輯播放清單 新增播放清單 名稱 播放清單名稱不能為空 編輯藝人 藝人名稱 藝人名稱不能為空 重複項目 略過重複項目 仍要新增 播放清單已有此曲目 播放清單已有 %d 首相同曲目 %d 首歌曲 %d 位藝人 %d 張專輯 %d 個播放清單 %d 週 %d 個月 %d 年 已匯入此播放清單 已將「%s」從播放清單移除 同步完成 復原 沒有歌詞 睡眠定時器 這首歌曲播放完畢 %d 分鐘 沒有可用的音源 沒有網路連線 連線逾時 未知的錯誤 喜歡 全部喜歡 取消喜歡 全部取消喜歡 隨機播放開啟 隨機播放關閉 重複播放關閉 重複播放此歌曲 重複播放佇列 全部歌曲 搜尋的歌曲 音樂播放器 設定 外觀 主題 使用動態主題 深色主題 跟隨系統 純黑 自訂導覽列 播放器 播放器文字對齊 歌詞文字位置 靠邊 靠左 置中 靠右 播放器滑桿樣式 預設 波浪 其他 預設啟動標籤 網格大小 內容 登入 尚未登入 預設內容語言 預設內容國家 系統預設 啟用 Proxy Proxy 種類 Proxy URL 重啟以套用變更 播放與音訊 音質 自動 播放佇列 保留播放佇列 開啟應用程式時還原上次的播放佇列 自動載入更多歌曲 當播放佇列快結束時,自動加入更多歌曲,如果可以的話 跳過無聲片段 標準化音量 發生錯誤時自動跳到下一首 讓你享受音樂不中斷 將音樂在清除任務時停止 等化器 儲存 快取 圖片快取 歌曲快取 最大快取大小 無限制 清除所有下載 圖片快取大小 清除圖片快取 歌曲快取大小 清除歌曲快取 已使用 %s 隱私 觀看記錄 暫停觀看記錄 清除觀看記錄 您確定要清除所有觀看記錄嗎? 搜尋記錄 暫停搜尋記錄 清除搜尋記錄 您確定要清除所有搜尋記錄嗎? 禁用截圖 當此選項開啟時,您無法截圖,也無法在「最近使用」中看到此應用程式的畫面。 使用 LrcLib 提供歌詞 使用酷狗音樂提供歌詞 移除不適當內容 備份與還原 備份 還原 已匯入的播放清單 成功建立備份 無法建立備份 無法還原備份 Discord 整合 Metrolist使用KizzyRPC函式庫來設定您的Discord狀態。這會用到Discord Gateway連線,可能會違反Discord服務條款,但是目前沒有使用者為此被停用帳號。使用此功能需自行承擔此風險。\n\nMetrolist只會提取你的token,所有東西都存在本機。 了解 選項 預覽 登入失敗 登出 啟用 Rich Presence 關於 應用程式版本 發現新版本 翻譯模型 清除翻譯模型 ================================================ FILE: app/src/main/res/xml/automotive_app_desc.xml ================================================ ================================================ FILE: app/src/main/res/xml/backup_rules.xml ================================================ ================================================ FILE: app/src/main/res/xml/data_extraction_rules.xml ================================================ ================================================ FILE: app/src/main/res/xml/music_widget_info.xml ================================================ ================================================ FILE: app/src/main/res/xml/network_security_config.xml ================================================ ================================================ FILE: app/src/main/res/xml/provider_paths.xml ================================================ ================================================ FILE: app/src/main/res/xml/recognizer_widget_info.xml ================================================ ================================================ FILE: app/src/main/res/xml/shortcuts.xml ================================================ ================================================ FILE: app/src/main/res/xml/turntable_widget_info.xml ================================================ ================================================ FILE: app/src/main/res/xml-v31/music_widget_info.xml ================================================ ================================================ FILE: app/src/main/res/xml-v31/recognizer_widget_info.xml ================================================ ================================================ FILE: app/src/main/res/xml-v31/turntable_widget_info.xml ================================================ ================================================ FILE: betterlyrics/build.gradle.kts ================================================ plugins { id("com.android.library") alias(libs.plugins.kotlin.serialization) } android { namespace = "com.metrolist.betterlyrics" compileSdk = 36 defaultConfig { minSdk = 26 } compileOptions { isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } } kotlin { jvmToolchain(21) } dependencies { implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.json) coreLibraryDesugaring(libs.desugaring) } ================================================ FILE: betterlyrics/src/main/AndroidManifest.xml ================================================ ================================================ FILE: betterlyrics/src/main/kotlin/com/metrolist/music/betterlyrics/BetterLyrics.kt ================================================ package com.metrolist.music.betterlyrics import com.metrolist.music.betterlyrics.models.TTMLResponse import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json object BetterLyrics { private val client by lazy { HttpClient(CIO) { install(ContentNegotiation) { json( Json { isLenient = true ignoreUnknownKeys = true }, ) } install(HttpTimeout) { requestTimeoutMillis = 15000 connectTimeoutMillis = 10000 socketTimeoutMillis = 15000 } defaultRequest { url("https://lyrics-api.boidu.dev") headers { append("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") append("Accept", "application/json") } } expectSuccess = false } } private suspend fun fetchTTML( artist: String, title: String, duration: Int = -1, album: String? = null, ): String? = runCatching { val response = client.get("/getLyrics") { parameter("s", title) parameter("a", artist) if (duration > 0) { parameter("d", duration) } if (!album.isNullOrBlank()) { parameter("al", album) } } if (response.status == HttpStatusCode.OK) { response.body().ttml } else { null } }.getOrNull() suspend fun getLyrics( title: String, artist: String, duration: Int, album: String? = null, ) = runCatching { // Use exact title and artist - no normalization to ensure correct sync // Normalizing can return wrong lyrics (e.g., radio edit vs original) val ttml = fetchTTML(artist, title, duration, album) ?: throw IllegalStateException("Lyrics unavailable") val parsedLines = TTMLParser.parseTTML(ttml) if (parsedLines.isEmpty()) { throw IllegalStateException("Failed to parse lyrics") } TTMLParser.toLRC(parsedLines) } suspend fun getAllLyrics( title: String, artist: String, duration: Int, album: String? = null, callback: (String) -> Unit, ) { getLyrics(title, artist, duration, album) .onSuccess { lrcString -> callback(lrcString) } } } ================================================ FILE: betterlyrics/src/main/kotlin/com/metrolist/music/betterlyrics/TTMLParser.kt ================================================ package com.metrolist.music.betterlyrics import org.w3c.dom.Element import org.w3c.dom.Node import javax.xml.parsers.DocumentBuilderFactory object TTMLParser { data class ParsedLine( val text: String, val startTime: Double, val words: List, val agent: String? = null, val isBackground: Boolean = false, val backgroundLines: List = emptyList() ) data class ParsedWord( val text: String, val startTime: Double, val endTime: Double ) private data class SpanInfo( val text: String, val startTime: Double, val endTime: Double, val hasTrailingSpace: Boolean ) // Helper function to get attribute by local name (handles namespace prefixes) private fun Element.getAttributeByLocalName(localName: String): String { // First try namespace-aware lookup val nsValue = getAttributeNS("http://www.w3.org/ns/ttml#metadata", localName) if (nsValue.isNotEmpty()) return nsValue // Then try with common prefixes val prefixedValue = getAttribute("ttm:$localName") if (prefixedValue.isNotEmpty()) return prefixedValue // Finally, search through all attributes val attrs = attributes for (i in 0 until attrs.length) { val attr = attrs.item(i) val attrName = attr.nodeName ?: continue if (attrName == localName || attrName.endsWith(":$localName")) { return attr.nodeValue ?: "" } } return "" } fun parseTTML(ttml: String): List { val lines = mutableListOf() try { val factory = DocumentBuilderFactory.newInstance() factory.isNamespaceAware = true val builder = factory.newDocumentBuilder() val doc = builder.parse(ttml.byteInputStream()) val pElements = doc.getElementsByTagName("p") for (i in 0 until pElements.length) { val pElement = pElements.item(i) as? Element ?: continue val begin = pElement.getAttribute("begin") if (begin.isNullOrEmpty()) continue val startTime = parseTime(begin) val spanInfos = mutableListOf() val backgroundLines = mutableListOf() // Get agent/vocalist info (ttm:agent attribute) val agent = pElement.getAttributeByLocalName("agent").ifEmpty { null } // Parse child nodes to preserve whitespace between spans val childNodes = pElement.childNodes for (j in 0 until childNodes.length) { val node = childNodes.item(j) when (node.nodeType) { Node.ELEMENT_NODE -> { val span = node as? Element if (span?.tagName?.lowercase() == "span") { // Check for background vocal role (ttm:role="x-bg") val role = span.getAttributeByLocalName("role") when (role) { "x-bg" -> { // Parse background vocal line val bgLine = parseBackgroundSpan(span, startTime) if (bgLine != null) { backgroundLines.add(bgLine) } } "x-translation", "x-roman" -> { // Skip translation and romanization spans } else -> { // Regular word span val wordBegin = span.getAttribute("begin") val wordEnd = span.getAttribute("end") val wordText = span.textContent?.trim() ?: "" if (wordText.isNotEmpty() && wordBegin.isNotEmpty() && wordEnd.isNotEmpty()) { val nextSibling = node.nextSibling val hasTrailingSpace = nextSibling?.nodeType == Node.TEXT_NODE && nextSibling.textContent?.contains(Regex("\\s")) == true spanInfos.add( SpanInfo( text = wordText, startTime = parseTime(wordBegin), endTime = parseTime(wordEnd), hasTrailingSpace = hasTrailingSpace ) ) } } } } } } } // Merge consecutive spans without whitespace between them into single words val words = mergeSpansIntoWords(spanInfos) val lineText = words.joinToString(" ") { it.text } // If no spans found, use text content directly (excluding background text) val finalText = if (lineText.isEmpty()) { getDirectTextContent(pElement).trim() } else { lineText } if (finalText.isNotEmpty()) { lines.add( ParsedLine( text = finalText, startTime = startTime, words = words, agent = agent, isBackground = false, backgroundLines = backgroundLines ) ) } } } catch (e: Exception) { return emptyList() } return lines } private fun parseBackgroundSpan(span: Element, parentStartTime: Double): ParsedLine? { val bgBegin = span.getAttribute("begin") val bgEnd = span.getAttribute("end") val bgStartTime = if (bgBegin.isNotEmpty()) parseTime(bgBegin) else parentStartTime val spanInfos = mutableListOf() val childNodes = span.childNodes for (j in 0 until childNodes.length) { val node = childNodes.item(j) if (node.nodeType == Node.ELEMENT_NODE) { val innerSpan = node as? Element if (innerSpan?.tagName?.lowercase() == "span") { val role = innerSpan.getAttributeByLocalName("role") // Skip translation and romanization spans if (role == "x-translation" || role == "x-roman") continue val wordBegin = innerSpan.getAttribute("begin") val wordEnd = innerSpan.getAttribute("end") val wordText = innerSpan.textContent?.trim() ?: "" if (wordText.isNotEmpty() && wordBegin.isNotEmpty() && wordEnd.isNotEmpty()) { val nextSibling = node.nextSibling val hasTrailingSpace = nextSibling?.nodeType == Node.TEXT_NODE && nextSibling.textContent?.contains(Regex("\\s")) == true spanInfos.add( SpanInfo( text = wordText, startTime = parseTime(wordBegin), endTime = parseTime(wordEnd), hasTrailingSpace = hasTrailingSpace ) ) } } } } val words = mergeSpansIntoWords(spanInfos) val lineText = words.joinToString(" ") { it.text } val finalText = if (lineText.isEmpty()) { getDirectTextContent(span).trim() } else { lineText } return if (finalText.isNotEmpty()) { ParsedLine( text = finalText, startTime = bgStartTime, words = words, agent = null, isBackground = true, backgroundLines = emptyList() ) } else null } private fun getDirectTextContent(element: Element): String { val sb = StringBuilder() val childNodes = element.childNodes for (i in 0 until childNodes.length) { val node = childNodes.item(i) if (node.nodeType == Node.TEXT_NODE) { sb.append(node.textContent) } else if (node.nodeType == Node.ELEMENT_NODE) { val el = node as? Element val role = el?.getAttributeByLocalName("role") ?: "" // Skip background, translation, and romanization spans if (role != "x-bg" && role != "x-translation" && role != "x-roman") { if (el?.tagName?.lowercase() == "span") { sb.append(el.textContent ?: "") } } } } return sb.toString() } private fun mergeSpansIntoWords(spanInfos: List): List { if (spanInfos.isEmpty()) return emptyList() val words = mutableListOf() var currentText = StringBuilder() var currentStartTime = spanInfos[0].startTime var currentEndTime = spanInfos[0].endTime for ((index, span) in spanInfos.withIndex()) { if (index == 0) { currentText.append(span.text) currentStartTime = span.startTime currentEndTime = span.endTime } else { // Check if previous span had trailing space (word boundary) val prevSpan = spanInfos[index - 1] if (prevSpan.hasTrailingSpace) { // Save current word and start new one if (currentText.isNotEmpty()) { words.add( ParsedWord( text = currentText.toString().trim(), startTime = currentStartTime, endTime = currentEndTime ) ) } currentText = StringBuilder(span.text) currentStartTime = span.startTime currentEndTime = span.endTime } else { // No space between spans - merge into same word (syllables) currentText.append(span.text) currentEndTime = span.endTime } } } // Add the last word if (currentText.isNotEmpty()) { words.add( ParsedWord( text = currentText.toString().trim(), startTime = currentStartTime, endTime = currentEndTime ) ) } return words } fun toLRC(lines: List): String { return buildString { lines.forEach { line -> val timeMs = (line.startTime * 1000).toLong() val minutes = timeMs / 60000 val seconds = (timeMs % 60000) / 1000 val centiseconds = (timeMs % 1000) / 10 // Add agent info if present val agentPrefix = if (!line.agent.isNullOrEmpty()) "{agent:${line.agent}}" else "" appendLine(String.format("[%02d:%02d.%02d]%s%s", minutes, seconds, centiseconds, agentPrefix, line.text)) if (line.words.isNotEmpty()) { val wordsData = line.words.joinToString("|") { word -> "${word.text}:${word.startTime}:${word.endTime}" } appendLine("<$wordsData>") } // Add background vocals as separate lines line.backgroundLines.forEach { bgLine -> val bgTimeMs = (bgLine.startTime * 1000).toLong() val bgMinutes = bgTimeMs / 60000 val bgSeconds = (bgTimeMs % 60000) / 1000 val bgCentiseconds = (bgTimeMs % 1000) / 10 appendLine(String.format("[%02d:%02d.%02d]{bg}%s", bgMinutes, bgSeconds, bgCentiseconds, bgLine.text)) if (bgLine.words.isNotEmpty()) { val bgWordsData = bgLine.words.joinToString("|") { word -> "${word.text}:${word.startTime}:${word.endTime}" } appendLine("<$bgWordsData>") } } } } } private fun parseTime(timeStr: String): Double { return try { when { timeStr.contains(":") -> { val parts = timeStr.split(":") when (parts.size) { 2 -> { val minutes = parts[0].toDouble() val seconds = parts[1].toDouble() minutes * 60 + seconds } 3 -> { val hours = parts[0].toDouble() val minutes = parts[1].toDouble() val seconds = parts[2].toDouble() hours * 3600 + minutes * 60 + seconds } else -> timeStr.toDoubleOrNull() ?: 0.0 } } else -> timeStr.toDoubleOrNull() ?: 0.0 } } catch (e: Exception) { 0.0 } } } ================================================ FILE: betterlyrics/src/main/kotlin/com/metrolist/music/betterlyrics/models/Track.kt ================================================ package com.metrolist.music.betterlyrics.models import kotlinx.serialization.Serializable @Serializable data class TTMLResponse( val ttml: String ) @Serializable data class SearchResponse( val results: List ) @Serializable data class Track( val title: String, val artist: String, val album: String? = null, val duration: Double, val lyrics: Lyrics? = null ) @Serializable data class Lyrics( val lines: List ) @Serializable data class Line( val text: String, val startTime: Double, val words: List? = null ) @Serializable data class Word( val text: String, val startTime: Double, val endTime: Double ) ================================================ FILE: build.gradle.kts ================================================ plugins { alias(libs.plugins.hilt) apply (false) alias(libs.plugins.kotlin.ksp) apply (false) alias(libs.plugins.kotlin.serialization) apply false } buildscript { repositories { google() mavenCentral() maven { setUrl("https://jitpack.io") } maven { setUrl("https://maven.aliyun.com/repository/public") } } dependencies { classpath(libs.gradle) classpath(kotlin("gradle-plugin", libs.versions.kotlin.get())) } } tasks.register("clean") { delete(rootProject.layout.buildDirectory) } subprojects { tasks.withType().configureEach { compilerOptions { if (project.findProperty("enableComposeCompilerReports") == "true") { arrayOf("reports", "metrics").forEach { freeCompilerArgs.add("-P") freeCompilerArgs.add("plugin:androidx.compose.compiler.plugins.kotlin:${it}Destination=${project.layout.buildDirectory}/compose_metrics") } } } } } ================================================ FILE: changelog.md ================================================ ---v13.3.0 # Major changes - Implemented song upload and delete functionality (@alltechdev) - Multiple playback fixes and reliability improvements (@alltechdev, @mostafaalagamy) - Fixed proguard rules causing issues with Reproducible Builds (@nyxiereal) - Fixed proguard rules removing Listen Together protobuf classes (@mostafaalagamy) - Added a playlist export option to the playlist context menu (@nyxiereal) ## Notable new features - Added a Play all action for the stats page (@isotjs) - Added a quick settings tile for recognizing music (@nyxiereal) - Added automatic sleep timer options and integrated fade-out volume handling (@isotjs) - Added a profile search filter (@alltechdev) - Added channel subscriptions for podcasts and artists (@alltechdev) ## Other improvements - Fixed cached images not clearing properly and cached covers not showing when offline (@nyxiereal) - Removed useless and stale strings from the codebase (@nyxiereal) - Refined the song details view (@omardotdev) - Added support for Mistral AI models (@nyxiereal) - Redesigned the lastfm integration settings (@omardotdev) - Fixed importing csv files crashing the app (@nyxiereal) - Prevent guest playback while in listen together (@nyxiereal) - Fixed podcasts not working for logged-out users (@alltechdev) - Updated dependencies (@nyxiereal) ## New Contributors * @isotjs made their first contribution in https://github.com/MetrolistGroup/Metrolist/pull/3090 **Full Changelog**: https://github.com/MetrolistGroup/Metrolist/compare/v13.2.1...v13.3.0 ---v13.2.1 >[!WARNING] >Listen Together doesn't work in v13.2.1! Use v13.2.0 if you need it. ## Hot Fixes - Fix interface lag issue - Fix navigate local playlists pinned in speed dial - Removed "cache songs only after playback has started" option **Full Changelog**: https://github.com/MetrolistGroup/Metrolist/compare/v13.2.0...v13.2.1 ---v13.2.0 # Major changes - Fixed playback breaking due to YouTube's February 2026 n-transform changes (@alltechdev) - Added full podcast library support (@mostafaalagamy & @alltechdev) - Redesigned loading, Changelog, and About screens (@adrielGGmotion) - Improved app startup time via parallelized home screen loading (@mostafaalagamy) ## Notable new features - Added an option to cache songs only after playback has started (@kairosci) - Added a music recognizer home screen widget (@mostafaalagamy) - Rewrote music recognizer in pure Kotlin, removing NDK dependency and reducing APK size (@mostafaalagamy) - Overhauled lyrics: added LyricsPlus provider, AI lyric fixes, untranslation support, and provider priority settings (@nyxiereal) - Changed listen together to use protobuf, lowering latency and improving reliability (@nyxiereal) - Added auto-approve setting for listen together song requests (@nyxiereal) - Added an option to persist the sleep timer default value (@johannesbrauer) - Added a dialog on logout to keep or clear library data (@alltechdev) ## Other improvements - Fixed backup restore causing playback errors due to stale auth credentials (@alltechdev) - The CSV import dialog is now scrollable (@kairosci) - Fixed Android 15 foreground service crashes (@kairosci) - Fixed a crash on the About screen on some devices (@mostafaalagamy) - Fixed home screen playlist navigation routing to wrong screen (@mostafaalagamy) - Fixed crash when creating local playlists (@mostafaalagamy) ## New Contributors * @johannesbrauer made their first contribution in https://github.com/MetrolistGroup/Metrolist/pull/2991 **Full Changelog**: https://github.com/MetrolistGroup/Metrolist/compare/v13.1.1...v13.2.0 ================================================ FILE: crowdin.yml ================================================ files: - source: /app/src/main/res/values/strings.xml translation: /app/src/main/res/values-%android_code%/strings.xml ================================================ FILE: development_guide.md ================================================ # Metrolist Dev Guide This file outlines the process of setting up a local dev environment for Metrolist. ## Prerequisites - JDK 21 - Android platform tools (if you don't have a keystore already) - protobuf-compiler v3.21 or newer ## Basic setup This has been tested on Linux, but should work on other platforms with some adjustments. ```bash git clone https://github.com/MetrolistGroup/Metrolist cd Metrolist git submodule update --init --recursive cd app bash generate_proto.sh cd .. [ ! -f "app/persistent-debug.keystore" ] && keytool -genkeypair -v -keystore app/persistent-debug.keystore -storepass android -keypass android -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US" || echo "Keystore already exists." ./gradlew :app:assembleFossDebug ls app/build/outputs/apk/universalFoss/debug/app-universal-foss-debug.apk ``` ### GitHub Secrets Configuration This project uses GitHub Secrets to securely store API keys for building releases. To set up the secrets: 1. Go to your GitHub repository settings 2. Navigate to **Settings** → **Secrets and variables** → **Actions** 3. Add the following repository secrets: - `LASTFM_API_KEY`: Your LastFM API key - `LASTFM_SECRET`: Your LastFM secret key 4. Get your LastFM API credentials from: https://www.last.fm/api/account/create **Note:** These secrets are automatically injected into the build process via GitHub Actions and are not visible in the source code. ================================================ FILE: fastlane/metadata/android/ar/full_description.txt ================================================ عميل YouTube Music Material 3 لأندرويد الميزات: - تشغيل أي أغنية أو فيديو من YouTube Music - التشغيل في الخلفية - اقتراحات سريعة مخصصة - إدارة المكتبة - تنزيل الأغاني وتخزينها للتشغيل دون اتصال بالإنترنت - البحث عن الأغاني، الألبومات، الفنانين، الفيديوهات، وقوائم التشغيل - كلمات الأغاني الحية - دعم تسجيل الدخول إلى حساب YouTube Music - مزامنة الأغاني، الفنانين، الألبومات، وقوائم التشغيل بين حسابك وجهازك - تخطي الصمت - استيراد قوائم التشغيل - تطبيع الصوت - تعديل السرعة/الدرجة الصوتية - إدارة قوائم التشغيل المحلية - إعادة ترتيب الأغاني في قائمة التشغيل - السمة الفاتحة - الداكنة - السوداء - الديناميكية - مؤقت النوم - Material 3 - وغيرها ================================================ FILE: fastlane/metadata/android/ar/short_description.txt ================================================ عميل YouTube Music Material 3 لأندرويد ================================================ FILE: fastlane/metadata/android/az-AZ/full_description.txt ================================================ Material 3 Android üçün YouTube Musiqi müştərisi Xüsusiyyətlər: - YT Music-dən istənilən mahnı və ya videonu səsləndirin - Fon oxutma - Fərdiləşdirilmiş sürətli seçimlər - Kitabxananın idarə edilməsi - Oflayn oxutmaq üçün mahnıları yükləyin və yaddaşda saxlamaq imkanı - Mahnıları, albomları, sənətçiləri, videoları və çalğı siyahılarını axtarışı - Canlı mahnı sözləri - YouTube Music hesabına giriş dəstəyi - Hesabınızdan mahnıların, sənətçilərin, albomların və çalğı siyahılarının sinxronizasiyası - Səssiz hissələri ötürmək imkanı - Pleylistləri idxal edin - Audio normallaşdırma - Tempi/pitchi tənzimləyin - Yerli pleylist idarə edilməsi - Pleylistdə və ya növbədə mahnıları yenidən sıralayın - Açıq - Tünd - qara - Dinamik mövzu - Yuxu taymeri - Material 3 - və s. ================================================ FILE: fastlane/metadata/android/az-AZ/short_description.txt ================================================ Android üçün Material 3 YouTube Music müştəri tətbiqi ================================================ FILE: fastlane/metadata/android/bg/short_description.txt ================================================ Material 3 YouTube Music клиент за Android ================================================ FILE: fastlane/metadata/android/ca/full_description.txt ================================================ Material 3 client de YouTube Music per a Android Característiques: - Reprodueix qualsevol cançó o vídeo de YT Music - Reproducció en segon pla - Seleccions ràpides personalitzades - Gestió de la biblioteca - Descarrega i desa cançons a la memòria cau per a la reproducció sense connexió - Cerca cançons, àlbums, artistes, vídeos i llistes de reproducció - Lletres en temps real - Suport per a l'inici de sessió del compte de YouTube Music - Sincronització de cançons, artistes, àlbums i llistes de reproducció, des del teu compte i cap a ell - Omet els silencis - Importa llistes de reproducció - Normalització d'àudio - Ajusta el tempo/el to - Gestió de llistes de reproducció locals - Reordena les cançons de les llistes de reproducció o la cua - Tema clar - fosc - negre - dinàmic - Temporitzador de son - Material 3 - etc. ================================================ FILE: fastlane/metadata/android/ca/short_description.txt ================================================ Client de YouTube Music amb Material 3 per Android ================================================ FILE: fastlane/metadata/android/cs-CZ/full_description.txt ================================================ Material 3 YouTube Music klient pro Android Funkce: - Přehrání jakékoli skladby nebo videa z YT Music - Přehrávání na pozadí - Personalizovaný rychlý výběr - Správa knihovny - Stahování a ukládání skladeb do mezipaměti pro offline přehrávání - Hledání skladeb, alb, umělců, videí a playlistů - Živé texty - Podpora přihlášení k účtu YouTube Music - Oboustranná synchronizace skladeb, umělců, alb a playlistů s účtem - Přeskočení ticha - Import playlistů - Normalizace zvuku - Úprava tempa/výšky - Místní správa playlistů - Změna pořadí skladeb v playlistu nebo ve frontě - Světlý / tmavý / černý / dynamický motiv - Časovač spánku - Material 3 - atd. ================================================ FILE: fastlane/metadata/android/cs-CZ/short_description.txt ================================================ Material 3 YouTube Music klient pro Android ================================================ FILE: fastlane/metadata/android/de-DE/full_description.txt ================================================ Material 3 YouTube Music Client für Android Funktionen: - Songs und Videos von YT Music abspielen - Hintergrund-Wiedergabe - Personalisierte Schnellauswahl - Bibliotheksverwaltung - Songs für die Offline-Wiedergabe herunterladen und cachen - Suche nach Songs, Alben, Künstlern, Videos und Playlists - Live-Songtexte - YouTube Music Konto Anmeldung unterstützt - Synchronisierung von Songs, Künstlern, Alben und Playlists zwischen Gerät und Konto - Stille überspringen - Playlists importieren - Audio-Normalisierung - Tempo und Tonhöhe anpassen - Lokale Playlist-Verwaltung - Songs in Playlist und Warteschlange umordnen - Hell - Dunkel - Schwarz - Dynamisches Thema - Schlaf-Timer - Material 3 - usw. ================================================ FILE: fastlane/metadata/android/de-DE/short_description.txt ================================================ Material 3 Youtube Music Client für Android ================================================ FILE: fastlane/metadata/android/en-US/full_description.txt ================================================ Material 3 YouTube Music client for Android Features: - Play any song or video from YT Music - Background playback - Personalized quick picks - Library management - Download and cache songs for offline playback - Search for songs, albums, artists, videos and playlists - Live lyrics - YouTube Music account login support - Syncing of songs, artists, albums and playlists, from and to your account - Skip silence - Import playlists - Audio normalization - Adjust tempo/pitch - Local playlist management - Reorder songs in playlist or queue - Light - Dark - black - Dynamic theme - Sleep timer - Material 3 - etc. ================================================ FILE: fastlane/metadata/android/en-US/short_description.txt ================================================ Material 3 YouTube Music client for Android ================================================ FILE: fastlane/metadata/android/en-US/title.txt ================================================ Metrolist ================================================ FILE: fastlane/metadata/android/es/full_description.txt ================================================ Cliente de YouTube Music Material 3 para Android Características: - Reproducir cualquier canción o video de YouTube Music - Reproducción en segundo plano - Sugerencias personalizadas rápidas - Gestión de la biblioteca - Descargar y almacenar canciones para reproducción sin conexión - Buscar canciones, álbumes, artistas, videos y listas de reproducción - Letras en vivo - Soporte para inicio de sesión en cuenta de YouTube Music - Sincronización de canciones, artistas, álbumes y listas de reproducción, desde y hacia tu cuenta - Saltar silencios - Importar listas de reproducción - Normalización de audio - Ajustar el tempo/timbre - Gestión de listas de reproducción locales - Reordenar canciones en la lista de reproducción o cola - Tema claro - oscuro - negro - dinámico - Temporizador de sueño - Material 3 - etc. ================================================ FILE: fastlane/metadata/android/es/short_description.txt ================================================ Cliente de YouTube Music en Material 3 para Android ================================================ FILE: fastlane/metadata/android/et/full_description.txt ================================================ Material 3 põhine YouTube Musicu klient Androidile Funktsionaalsused: - Kuula või vaata kõiki lugusid YT Musicust - Taasesitus taustal - Isiklikud kiirvalikud - Muusikakogu haldus - Laadi lugusid alla hilisemaks esitamiseks ilma võrguühendust kasutamata - Otsi lugusid, albumeid, esitajaid, videoid ja esitusloendeid - Laulusõnad kuulamise ajal - YouTube Musicu kasutajakonto pruukimise võimalus - Lugude, esitajate, albumite ja esitusloendite sünkroonimine sinu kasutajakontoga - Vaikuse vahelejätmine - Esitusloendite import - Heli normaliseerimine - Kohenda tempot ja helikõrgust - Esitusloendite kohaliku haldamise võimalus - Võimalus muuta lugude järjestust esitusloendis või esitusjärjekorras - Hele, tume, must ja dünaamiline kujundus - Unetaimer - Material 3 kujunduskeel - ja palju muud. ================================================ FILE: fastlane/metadata/android/et/short_description.txt ================================================ Material 3 põhine YouTube Musicu klient Androidile ================================================ FILE: fastlane/metadata/android/eu-ES/full_description.txt ================================================ Material 3 YouTube Music aplikazioa Android-erako Ezaugarriak: - YT Music-eko edozein abesti edo bideo erreproduzitu - Atzeko planoan erreprodukzioa - Aukera azkarrak pertsonalizatuta - Liburutegiaren kudeaketa - Abestiak deskargatu eta cachean gorde lineaz kanpoko erreprodukziorako - Abestiak, albumak, artistak, bideoak eta zerrenda erreproduzigarriak bilatu - Letrak zuzenean - YouTube Music kontuarekin saioa hasteko euskarria - Abestiak, artistak, albumak eta zerrenda erreproduzigarriak zure kontuarekin sinkronizatzea (eta alderantziz) - Isiltasuna saltatu - Zerrenda erreproduzigarriak inportatu - Audioa normalizatzea - Tenpoa/tinbrea doitzea - Tokiko zerrenda erreproduzigarriak kudeatzea - Zerrenda edo ilarako abestiak berrantolatzea - Argi - Ilun - Beltz - Dinamiko gaiak - Lo-ordutegia - Material 3 - eta abar. ================================================ FILE: fastlane/metadata/android/eu-ES/short_description.txt ================================================ Material 3 Youtube Music Android-erako bezeroa ================================================ FILE: fastlane/metadata/android/fil/full_description.txt ================================================ Materyal 3 Youtube Music na kliyente na para sa Android Mga Katangian: - Magpatugtog ng kanta o bidyo na mula sa YT Music - Magpatugtog ng kanta na kahit wala sa aplikasyon - Personalisadong listahan ng mabilisang pagpilihan - Pamamahala ng Aklatan - Magdownload at magcache ng mga kanta para makapagpatugtog ng mga kanta na kahit walang internet - Maghanap ng mga kanta, mga album, mga artista at mga playlist - Mga Liriko - Suporta para sa paglogin sa iyong YouTube Music account - Singkronisadong mga kanta, mga artista, mga album, at mga playlist, mula at sa iyong account - Laktawan ang tahimik na parte ng kanta - Magimport ng mga playlist - Normalisasyon ng audio - I-adjust ang tempo/tinis ng kanta - Lokal na pamamahala sa iyong playlist - Iayos ang pagkakasunod-sunod ng mga kanta sa playlist o sa listahan ng mga kanta - Maliwanag - Madilim - Itim - Paiba-ibang tema - Orasan ang pagpatugtog ng mga kanta - Materyal 3 - iba pa. ================================================ FILE: fastlane/metadata/android/fil/short_description.txt ================================================ Materyal 3 Youtube Music na kliyente na para sa Android ================================================ FILE: fastlane/metadata/android/fr-FR/full_description.txt ================================================ Client YouTube Music Material 3 pour Android Fonctionnalités : - Lecture de n'importe quel morceau ou vidéo depuis YouTube Music - Lecture en arrière-plan - Sélection rapide personnalisée - Gestion de la bibliothèque - Téléchargement et mise en cache des morceaux pour une écoute hors ligne - Recherche de morceaux, albums, artistes, vidéos et playlists - Affichage des paroles en direct - Connexion à votre compte YouTube Music - Synchronisation des morceaux, artistes, albums et playlists avec votre compte - Suppression des silences - Importation de playlists - Normalisation audio - Réglage du tempo et de la tonalité - Gestion des playlists locales - Réorganisation des morceaux dans les playlists et la file d'attente - Thèmes : Clair, Sombre, Noir, Dynamique - Minuterie de mise en veille - Material 3 - Etc. ================================================ FILE: fastlane/metadata/android/fr-FR/short_description.txt ================================================ Client YouTube Music Material 3 pour Android ================================================ FILE: fastlane/metadata/android/id/full_description.txt ================================================ Klien YouTube Music berbasis Material 3 untuk Android Fitur: - Putar lagu atau video apa pun dari YouTube Music - Pemutaran di latar belakang - Pilihan cepat yang dipersonalisasi - Pengelolaan pustaka - Unduh dan simpan lagu untuk diputar luring - Cari lagu, album, artis, video, dan daftar putar - Lirik langsung - Dukungan masuk akun YouTube Music - Sinkronisasi lagu, artis, album, dan daftar putar dari dan ke akun Anda - Lewati bagian hening - Impor daftar putar - Normalisasi audio - Atur tempo/tinggi nada - Pengelolaan daftar putar lokal - Ubah urutan lagu di daftar putar atau antrean - Tema terang, gelap, hitam, dan dinamis - Pengatur waktu tidur - Material 3 - dan lainnya. ================================================ FILE: fastlane/metadata/android/id/short_description.txt ================================================ Klien YouTube Music berbasis Material 3 untuk Android ================================================ FILE: fastlane/metadata/android/it/full_description.txt ================================================ Client di YouTube Music in Material 3 per Android Caratteristiche: - Riproduzione di qualsiasi canzone o video da YouTube Music - Riproduzione in background - Suggerimenti rapidi personalizzati - Gestione della libreria - Scarica e memorizza canzoni per la riproduzione offline - Ricerca di canzoni, album, artisti, video e playlist - Testo in tempo reale - Supporto per l'accesso all'account di YouTube Music - Sincronizzazione di canzoni, artisti, album e playlist con il tuo account - Rimozione dei silenzi - Importazione di playlist - Normalizzazione dell'audio - Regolazione del tempo/timbro - Gestione delle playlist locali - Riorganizza le canzoni nella playlist o nella coda - Tema chiaro, scuro, nero o dinamico - Timer per il sonno - Material 3 - ecc. ================================================ FILE: fastlane/metadata/android/it/short_description.txt ================================================ Client di YouTube Music in Material 3 per Android ================================================ FILE: fastlane/metadata/android/it/title.txt ================================================ Metrolist ================================================ FILE: fastlane/metadata/android/ko-KR/full_description.txt ================================================ 머티리얼 3 안드로이드 유튜브 뮤직 클라이언트 특징: - 유튜브 뮤직의 모든 곡 재생 - 백그라운드 재생 - 개인화된 추천 - 라이브러리 관리 - 오프라인 재생을 위한 캐시/다운로드 - 곡, 아티스트, 동영상, 재생목록 검색 - 살아있는 가사 - 유튜브 뮤직 로그인 - 계정과 동기화 - 무음 건너뛰기 - 재생목록 가져오기 - 음량 정규화 - 템포/피치 조절 - 로컬 재생목록 관리 - 재생목록/큐에서 곡 재정렬 - 라이트 - 다크 - 블랙 - 다이나믹 테마 - 수면 타이머 - Material 3 - 등등. ================================================ FILE: fastlane/metadata/android/ko-KR/short_description.txt ================================================ 머티리얼 3 안드로이드 유튜브 뮤직 클라이언트 ================================================ FILE: fastlane/metadata/android/lt/full_description.txt ================================================ „YouTube Music” grotuvas, naudojantis „Material 3”, skirtas „Android” Funkcijos: - Grok bet kokią dainą ar klipą iš „YT Music”  - Grojimas fone  - Suasmeninti greitieji pasiūlymai - Bibliotekos tvarkymas. - Atsisiųsk arba išsaugok podėlyje dainas klausymui be ryšio - Ieškok dainų, albumų, kūrėjų ir grojaraščių - Dainų tekstas tiesiogiai. - „YouTube Music” paskyros prijungimo palaikymas - Dainų, albumų, kūrėjų ir grojaraščių sinchronizacija iš ir į tavo paskyrą - Tylos praleidimas. - Grojaraščių įkėlimas. - Garso normalizacija. - Tempo ir garso aukščio reguliavimas. - Vietinių grojaraščių tvarkymas - Dainų tvarkos keitimas grojaraštyje arba grojimo eilėje. - Šviesioji - Tamsioji - Juodoji - Prisitaikanti tema - Miego laikmatis - „Material 3”. - ir daugiau. ================================================ FILE: fastlane/metadata/android/lt/short_description.txt ================================================ Material 3 YouTube Muzikos klientas skirtas Android ================================================ FILE: fastlane/metadata/android/mfe/full_description.txt ================================================ Client Material 3 YouTube Music pu Android Fonksyonaliyé: - Zwé lamizik ek vidéo ki lor YT Music - Zwé dan background. - Choix rapide personalizé. - Zere ou librairie. - Download bann lamizik pu zwé offline - Rod bann lamizik, album, artist, vidéo ek playlist - Bann parole en temps réel. - Login ek compte YouTube Music - Senkroniz bann lamizik, artist, album ek artist depi ek vers ou compte - Sote bann silans. - Import bann playlist. - Normalizasyon audio. - Aziste tempo ek pitch. - Zere ou playlist lokal - Aranz lorde ou lamizik dan playlist ek queue. - Thème Clair - Som - Nwar - Dynamik - Minitere pu somey - Material 3. - etc. ================================================ FILE: fastlane/metadata/android/mfe/short_description.txt ================================================ Client Material 3 YouTube Music pu Android ================================================ FILE: fastlane/metadata/android/pt/full_description.txt ================================================ Cliente em Material 3 do YouTube Music para Android Recursos: - Toque qualquer música ou vídeo do YT Music - Reprodução em segundo plano - Escolhas rápidas personalizadas - Gestão da biblioteca - Descarga e cache de músicas para a reprodução off-line - Pesquise por músicas, álbuns, artistas, vídeos e listas - Letra sincronizada - Apoia à login com a conta do YouTube Music - Sincronização de músicas, artistas, álbuns e listas, de e para a sua conta - Pular silêncio - Importar listas - Normalização de áudio - Ajuste a velocidade e tonalidade do áudio - Gestão de listas locais - Reordene as músicas numa lista ou fila - Temas claro, escuro, preto e dinâmico - Timer para dormir - Material 3 - entre outros. ================================================ FILE: fastlane/metadata/android/pt/short_description.txt ================================================ Cliente em Material 3 do YouTube Music para Android ================================================ FILE: fastlane/metadata/android/pt-BR/full_description.txt ================================================ Cliente em Material 3 do YouTube Music para Android Recursos: - Toque qualquer música ou vídeo do YT Music - Reprodução em segundo plano - Escolhas rápidas personalizadas - Gerenciamento da biblioteca - Download e cache de músicas para reprodução off-line - Pesquise por músicas, álbuns, artistas, vídeos e playlists - Letra sincronizada - Suporte a login com a conta do YouTube Music - Sincronização de músicas, artistas, álbuns, e playlists, de e para a sua conta - Pular silêncio - Importar playlists - Normalização de áudio - Ajuste a velocidade e tonalidade do áudio - Gerenciamento de playlists locais - Reordene as músicas em uma playlist ou fila - Temas claro, escuro, preto, e dinâmico - Timer para soneca - Material 3 - entre outros. ================================================ FILE: fastlane/metadata/android/pt-BR/short_description.txt ================================================ Cliente em Material 3 do YouTube Music para Android ================================================ FILE: fastlane/metadata/android/ro/full_description.txt ================================================ Un client Material 3 de YouTube Music pentru Android Caracteristici: - Redă orice melodie sau videoclip de pe YT Music - Redare în fundal - Alegeri rapide personalizate - Gestionarea bibliotecii - Descarcă și pune în cache melodii pentru redare offline - Caută melodii, albume, artiști, videoclipuri și playlisturi - Versuri live - Suport pentru autentificarea la contul de YouTube Music - Sincronizarea melodiilor, artiștilor, albumelor și playlisturilor de pe contul tău și pe contul tău - Omite momentele de liniște - Importă playlisturi - Normalizare audio - Ajustează tempo-ul/înălțimea - Gestionarea locală a playlisturilor - Reordonează melodiile în playlist sau coadă - Temă luminoasă, întunecată, neagră și dinamică - Temporizator de somn - Material 3 - etc. ================================================ FILE: fastlane/metadata/android/ro/short_description.txt ================================================ Un client Material 3 de Youtube Music pentru Android ================================================ FILE: fastlane/metadata/android/ru-RU/full_description.txt ================================================ Клиент YouTube Music в стиле Material 3 для Android Возможности: - Воспроизведение любых треков и видео из YouTube Music - Фоновое воспроизведение - Персонализированный быстрый выбор - Управление библиотекой - Скачивание и кэширование треков для офлайн-воспроизведения - Поиск треков, альбомов, исполнителей, видео и плейлистов - Тексты песен в реальном времени - Поддержка входа в аккаунт YouTube Music - Синхронизация треков, исполнителей, альбомов и плейлистов с вашим аккаунтом и обратно - Пропуск тишины - Импорт плейлистов - Нормализация аудио - Регулировка темпа и высоты тона - Управление локальными плейлистами - Изменение порядка треков в плейлисте или очереди - Светлая, тёмная, чёрная и динамическая темы оформления - Таймер сна - Material 3 - и др. ================================================ FILE: fastlane/metadata/android/ru-RU/short_description.txt ================================================ Клиент YouTube Music в стиле Material 3 для Android ================================================ FILE: fastlane/metadata/android/ru-RU/title.txt ================================================ Metrolist ================================================ FILE: fastlane/metadata/android/sk/full_description.txt ================================================ Material 3 YouTube Music klient pre Android Funkcie: - Prehrajte si akúkoľvek skladbu alebo video z YT Music - Prehrávanie na pozadí - Personalizované rýchle výbery - Správa knižnice - Sťahovanie a ukladanie skladieb do vyrovnávacej pamäte pre prehrávanie offline - Vyhľadávanie skladieb, albumov, interpretov, videí a zoznamov skladieb - Živé texty - Podpora prihlásenia do účtu YouTube Music - Synchronizácia skladieb, interpretov, albumov a zoznamov skladieb z vášho účtu a do neho - Preskočiť tiché momenty v skladbách - Importovať zoznamy skladieb - Normalizácia zvuku - Úprava tempa/výšky tónu - Správa lokálnych playlistov - Zmena poradia skladieb v zozname skladieb alebo v rade - Svetlá - Tmavá - Čierna - Dynamická téma - Časovač vypnutia - Materiál 3 - atd. ================================================ FILE: fastlane/metadata/android/sk/short_description.txt ================================================ Materiál 3 YouTube Music klient pre Android ================================================ FILE: fastlane/metadata/android/sl/full_description.txt ================================================ Material 3 odjemalec YouTube Music za Android Lastnosti: - Predvajajte katerokoli skladbo ali videoposnetek iz YT Music. - Predvajanje v ozadju - Osebne prilagojene hitre izbire - Upravljanje knjižnice - Prenos in predpomnjenje skladb za predvajanje brez povezave - Iskanje skladb, albumov, izvajalcev, videoposnetkov in seznamov predvajanja - Besedila v živo - Podpora za prijavo v račun YouTube Music - Sinhronizacija skladb, izvajalcev, albumov in seznamov predvajanja iz računa in v račun - Preskakanje tišine - Uvoz seznamov predvajanja - Normalizacija zvoka - Nastavitev tempa/višine tona - Upravljanje lokalnih seznamov predvajanja - Spreminjanje vrstnega reda skladb na seznamu ali v vrsti predvajanja - Svetla, temna, črna in dinamična tema - Časovnik za spanje - Material 3 - itd. ================================================ FILE: fastlane/metadata/android/sl/short_description.txt ================================================ Material 3 odjemalec YouTube Music za Android ================================================ FILE: fastlane/metadata/android/te-IN/full_description.txt ================================================ ఆండ్రాయిడ్ కోసం మెటీరియల్ 3 పదార్థం రూపకల్పన యూట్యూబ్ మ్యూజిక్ అనువర్తనం లక్షణాలు: -యూట్యూబ్ మ్యూజిక్ నుండి ఏ పాట లేదా వీడియోనైనా వినగలగడం -వెనుకన కూడా పాట వినడం (పాట తెర మూసినా కూడా పాటలు కొనసాగుతుంది). -వ్యక్తిగతీకరించిన త్వరిత ఎంపికలు. -సంగీత గ్రంథాలయ నిర్వహణ. -అంతర్జాలం లేకుండా వినడానికి పాటలను దిగుమతి చేసుకోవడం మరియు తాత్కాలికంగా నిల్వ చేయడం -పాటలు, ఆల్బమ్‌లు, కళాకారులు, చలనచిత్రాలు మరియు పాటల జాబితాల కోసం శోధించడం -ప్రత్యక్ష గీతంలోని పద్యాలు చూపించడం -యూట్యూబ్ మ్యూజిక్ ఖాతా ప్రవేశం సౌకర్యం -మీ యూట్యూబ్ మ్యూజిక్ ఖాతా నుండి మరియు ఖాతాకు పాటలు, కళాకారులు, ఆల్బమ్‌లు మరియు పాటల జాబితాల సమకాలీకరించడం (సింక్ చేయడం) -నిశ్శబ్ద భాగాలను దాటేయడం. -జాబితాలను దిగుమతి చేసుకోవడం. -ధ్వని సాధారణీకరణ ( సమాన స్థాయికి తేవడం). -వేగం/స్వరాన్ని ( శృతిని) మార్చగలగడం. -స్థానిక జాబితా నిర్వహణ -జాబితా లేదా క్యూలో మీకు నచ్చినట్టుగా పాటల క్రమాన్ని మార్చగలగడం - అప్ లో వెలుతురు - ముదురు రంగు - నలుపు - మార్చుకునే రంగు రూపకల్పన -సమయాలలో పాటలు వీనే అప్పుడు మనం పెట్టిన సమయసూచిక సమయం కి పాటలు ఆగిపోతాయి. -మెటీరియల్ 3 పదార్థం రూపకల్పన. -ఇంకా మరెన్నో. ================================================ FILE: fastlane/metadata/android/te-IN/short_description.txt ================================================ ఆండ్రాయిడ్ కోసం మెటీరియల్ 3 యూట్యూబ్ మ్యూజిక్ క్లయింట్ ================================================ FILE: fastlane/metadata/android/tr/full_description.txt ================================================ YouTube Music Material 3 İstemcisi (Android için) Özellikler: - YouTube Music'ten her türlü şarkı veya video çalma - Arka planda çalma - Kişiselleştirilmiş hızlı öneriler - Kütüphane yönetimi - Çevrimdışı çalma için şarkıları indirip önbelleğe alma - Şarkılar, albümler, sanatçılar, videolar ve çalma listeleri arama - Canlı şarkı sözleri - YouTube Music hesabı ile giriş desteği - Hesabınızla şarkı, sanatçı, albüm ve çalma listelerinin senkronizasyonu - Sessizlik atlama - Çalma listelerini içe aktarma - Ses normalizasyonu - Tempo/ton ayarı - Yerel çalma listesi yönetimi - Çalma listesinde veya sırada şarkıları yeniden düzenleme - Açık - Koyu - Siyah - Dinamik tema - Uyku zamanlayıcı - Material 3 - vb. ================================================ FILE: fastlane/metadata/android/tr/short_description.txt ================================================ Android için Material 3 YouTube Müzik istemcisi ================================================ FILE: fastlane/metadata/android/uk-UA/full_description.txt ================================================ Клієнт YouTube Music у стилі Material 3 для Android Можливості: - Відтворення будь-якої пісні або відео з YouTube Music - Фонове відтворення - Персоналізовані швидкі добірки - Керування бібліотекою - Завантаження та кешування пісень для офлайн-прослуховування - Пошук пісень, альбомів, виконавців, відео та списків відтворення - Живі тексти пісень - Підтримка входу через акаунт YouTube Music - Синхронізація пісень, виконавців, альбомів і списків відтворення між пристроєм та акаунтом - Пропуск тиші - Імпорт списків відтворення - Нормалізація гучності - Налаштування темпу й тональності - Керування локальними списками відтворення - Зміна порядку пісень у списках відтворення або черзі - Світла, темна, чорна та динамічна теми - Таймер сну - Інтерфейс Material 3 - Та інше ================================================ FILE: fastlane/metadata/android/uk-UA/short_description.txt ================================================ Неофіційний додаток YouTube Music з дизайном Material 3 для Android ================================================ FILE: gradle/gradle-daemon-jvm.properties ================================================ #This file is generated by updateDaemonJvm toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect toolchainVersion=21 ================================================ FILE: gradle/libs.versions.toml ================================================ [versions] androidGradlePlugin = "9.1.0" json = "20251224" kotlin = "2.3.10" compose = "1.10.4" lifecycle = "2.10.0" material3 = "1.5.0-alpha15" appcompat = "1.7.1" media3 = "1.7.1" mediarouter = "1.8.1" castFramework = "22.2.0" room = "2.8.4" hilt = "2.59.2" ktor = "3.4.1" ksp = "2.3.5" jsoup = "1.22.1" coil = "3.4.0" ucrop = "2.2.11" guava = "33.5.0-jre" coroutinesGuava = "1.10.2" concurrentFutures = "1.3.0" activity = "1.12.4" hiltNavigation = "1.3.0" datastore = "1.2.0" composeReorderable = "3.0.0" shimmer = "1.3.3" palette = "1.0.0" apacheLang3 = "3.20.0" brotli = "0.1.2" desugaring = "2.1.5" junit = "4.13.2" timber = "5.0.1" materialKolor = "4.1.1" kuromojiIpadic = "0.9.0" newpipeextractor = "v0.26.0" tinypinyin = "2.0.3" protobuf = "4.34.0" [libraries] guava = { module = "com.google.guava:guava", version.ref = "guava" } coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutinesGuava" } concurrent-futures = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "concurrentFutures" } gradle = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" } activity = { module = "androidx.activity:activity-compose", version.ref = "activity" } hilt-navigation = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigation" } datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } compose-ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "compose" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-animation = { module = "androidx.compose.animation:animation-graphics", version.ref = "compose" } compose-reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "composeReorderable" } viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } ucrop = { module = "com.github.yalantis:ucrop", version.ref = "ucrop" } shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "shimmer" } palette = { module = "androidx.palette:palette-ktx", version.ref = "palette" } media3 = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" } media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } media3-cast = { module = "androidx.media3:media3-cast", version.ref = "media3" } mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "mediarouter" } cast-framework = { module = "com.google.android.gms:play-services-cast-framework", version.ref = "castFramework" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } apache-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "apacheLang3" } hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" } ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } json = { module = "org.json:json", version.ref = "json" } brotli = { module = "org.brotli:dec", version.ref = "brotli" } desugaring = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugaring" } junit = { module = "junit:junit", version.ref = "junit" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } newpipeextractor = { module = "com.github.TeamNewPipe:NewPipeExtractor", version.ref = "newpipeextractor" } kuromoji-ipadic = { module = "com.atilika.kuromoji:kuromoji-ipadic", version.ref = "kuromojiIpadic" } tinypinyin = { module = "com.github.promeG:tinypinyin", version.ref = "tinypinyin" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" } [plugins] compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ ## For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx1024m -XX:MaxPermSize=256m # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 # # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true #Sat Nov 19 15:59:34 CST 2022 org.gradle.jvmargs=-Xmx4096M -Dkotlin.daemon.jvm.options\="-Xmx4096M" -XX:+UseParallelGC android.useAndroidX=true android.enableJetifier=false org.gradle.unsafe.configuration-cache=true android.nonTransitiveRClass=false # Jetifier is disabled - no need for ignorelist # Performance improvements org.gradle.parallel=true org.gradle.daemon=true org.gradle.configureondemand=false # Suppress deprecated warnings android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,android.nonFinalResIds # Increase timeouts for JitPack downloads (fixes timeout issues with NewPipeExtractor) systemProp.org.gradle.internal.http.connectionTimeout=180000 systemProp.org.gradle.internal.http.socketTimeout=180000 # Disable caching for SNAPSHOT dependencies to avoid timeout issues org.gradle.caching=false ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: innertube/.gitignore ================================================ /build ================================================ FILE: innertube/build.gradle.kts ================================================ plugins { id("com.android.library") alias(libs.plugins.kotlin.serialization) } android { namespace = "com.metrolist.innertube" compileSdk = 36 defaultConfig { minSdk = 26 } compileOptions { isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } } kotlin { jvmToolchain(21) } dependencies { implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.json) implementation(libs.ktor.client.encoding) implementation(libs.brotli) implementation(libs.newpipeextractor) implementation(libs.timber) testImplementation(libs.junit) coreLibraryDesugaring(libs.desugaring) } ================================================ FILE: innertube/src/main/AndroidManifest.xml ================================================ ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/InnerTube.kt ================================================ package com.metrolist.innertube import com.metrolist.innertube.models.Context import com.metrolist.innertube.models.MediaInfo import com.metrolist.innertube.models.ReturnYouTubeDislikeResponse import com.metrolist.innertube.models.YouTubeClient import com.metrolist.innertube.models.YouTubeLocale import com.metrolist.innertube.models.body.* import com.metrolist.innertube.models.response.NextResponse import com.metrolist.innertube.utils.parseCookieString import com.metrolist.innertube.utils.sha1 import io.ktor.client.* import io.ktor.client.call.body import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.* import io.ktor.client.plugins.compression.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.* import io.ktor.client.statement.bodyAsText import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import java.net.Proxy import java.io.IOException import kotlinx.coroutines.delay import java.util.* import kotlin.io.encoding.Base64 import timber.log.Timber import kotlin.io.encoding.ExperimentalEncodingApi /** * Provide access to InnerTube endpoints. * For making HTTP requests, not parsing response. */ @OptIn(ExperimentalEncodingApi::class) class InnerTube { private var httpClient = createClient() var locale = YouTubeLocale( gl = Locale.getDefault().country, hl = Locale.getDefault().toLanguageTag() ) var visitorData: String? = null var dataSyncId: String? = null var cookie: String? = null set(value) { field = value cookieMap = if (value == null) emptyMap() else parseCookieString(value) } private var cookieMap = emptyMap() var proxy: Proxy? = null set(value) { field = value httpClient.close() httpClient = createClient() } var proxyAuth: String? = null var useLoginForBrowse: Boolean = false @OptIn(ExperimentalSerializationApi::class) private fun createClient() = HttpClient(OkHttp) { expectSuccess = true install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true explicitNulls = false encodeDefaults = true }) } install(ContentEncoding) { gzip(0.9F) deflate(0.8F) } // Enhanced network configuration for better performance engine { config { // Connection pool settings for better connection reuse connectionPool( okhttp3.ConnectionPool( 10, // maxIdleConnections 5, // keepAliveDuration java.util.concurrent.TimeUnit.MINUTES ) ) // Timeout configurations connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) readTimeout(60, java.util.concurrent.TimeUnit.SECONDS) writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS) // Enable HTTP/2 for better performance protocols(listOf(okhttp3.Protocol.HTTP_2, okhttp3.Protocol.HTTP_1_1)) // Retry on connection failure retryOnConnectionFailure(true) // Cache configuration for better performance cache( okhttp3.Cache( directory = java.io.File(System.getProperty("java.io.tmpdir"), "http_cache"), maxSize = 50L * 1024L * 1024L // 50 MB ) ) // Apply proxy configuration this@InnerTube.proxy?.let { proxyConfig -> proxy(proxyConfig) } // Apply proxy authentication this@InnerTube.proxyAuth?.let { auth -> proxyAuthenticator { _, response -> response.request.newBuilder() .header("Proxy-Authorization", auth) .build() } } } } // Request timeout configuration install(HttpTimeout) { requestTimeoutMillis = 60000 connectTimeoutMillis = 30000 socketTimeoutMillis = 60000 } defaultRequest { url(YouTubeClient.API_URL_YOUTUBE_MUSIC) // Add common headers for better compatibility header("Accept", "application/json") header("Accept-Language", "en-US,en;q=0.9") header("Cache-Control", "no-cache") } } private fun HttpRequestBuilder.ytClient(client: YouTubeClient, setLogin: Boolean = false) { contentType(ContentType.Application.Json) headers { append("X-Goog-Api-Format-Version", "1") append("X-YouTube-Client-Name", client.clientId /* Not a typo. The Client-Name header does contain the client id. */) append("X-YouTube-Client-Version", client.clientVersion) append("X-Origin", YouTubeClient.ORIGIN_YOUTUBE_MUSIC) append("Referer", YouTubeClient.REFERER_YOUTUBE_MUSIC) visitorData?.let { append("X-Goog-Visitor-Id", it) } if (setLogin && client.loginSupported) { cookie?.let { cookie -> append("cookie", cookie) if ("SAPISID" !in cookieMap) return@let val currentTime = System.currentTimeMillis() / 1000 val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} ${YouTubeClient.ORIGIN_YOUTUBE_MUSIC}") append("Authorization", "SAPISIDHASH ${currentTime}_${sapisidHash}") } } } userAgent(client.userAgent) parameter("prettyPrint", false) } /** * Simple retry wrapper for transient IO errors (socket aborts, timeouts). * Retries the given block up to [maxAttempts] times with exponential backoff. * Cancellation is respected since [delay] will throw if the coroutine is cancelled. */ private suspend fun withRetry( maxAttempts: Int = 3, initialDelay: Long = 500L, factor: Double = 2.0, block: suspend () -> T, ): T { var currentDelay = initialDelay var attempt = 0 while (true) { try { return block() } catch (e: IOException) { attempt++ if (attempt >= maxAttempts) throw e delay(currentDelay) currentDelay = (currentDelay * factor).toLong() } } } suspend fun search( client: YouTubeClient, query: String? = null, params: String? = null, continuation: String? = null, ) = withRetry { httpClient.post("search") { ytClient(client, setLogin = useLoginForBrowse) setBody( SearchBody( context = client.toContext( locale, visitorData, if (useLoginForBrowse) dataSyncId else null ), query = query, params = params ) ) parameter("continuation", continuation) parameter("ctoken", continuation) } } suspend fun player( client: YouTubeClient, videoId: String, playlistId: String?, signatureTimestamp: Int?, poToken: String? = null, ) = withRetry { httpClient.post("player") { ytClient(client, setLogin = true) setBody( PlayerBody( context = client.toContext(locale, visitorData, dataSyncId).let { if (client.isEmbedded) { it.copy( thirdParty = Context.ThirdParty( embedUrl = "https://www.youtube.com/watch?v=${videoId}" ) ) } else it }, videoId = videoId, playlistId = playlistId, playbackContext = if (client.useSignatureTimestamp && signatureTimestamp != null) { PlayerBody.PlaybackContext( PlayerBody.PlaybackContext.ContentPlaybackContext( signatureTimestamp ) ) } else null, serviceIntegrityDimensions = if (client.useWebPoTokens && poToken != null) { PlayerBody.ServiceIntegrityDimensions(poToken) } else null, ) ) } } suspend fun registerPlayback( url: String, cpn: String, playlistId: String?, client: YouTubeClient = YouTubeClient.WEB_REMIX, ) = withRetry { httpClient.get(url) { ytClient(client, true) parameter("ver", "2") parameter("c", client.clientName) parameter("cpn", cpn) if (playlistId != null) { parameter("list", playlistId) parameter("referrer", "https://music.youtube.com/playlist?list=$playlistId") } } } suspend fun browse( client: YouTubeClient, browseId: String? = null, params: String? = null, continuation: String? = null, setLogin: Boolean = false, ) = withRetry { httpClient.post("browse") { ytClient(client, setLogin = setLogin || useLoginForBrowse) setBody( BrowseBody( context = client.toContext( locale, visitorData, if (setLogin || useLoginForBrowse) dataSyncId else null ), browseId = browseId, params = params, continuation = continuation ) ) } } suspend fun next( client: YouTubeClient, videoId: String?, playlistId: String?, playlistSetVideoId: String?, index: Int?, params: String?, continuation: String? = null, ) = withRetry { httpClient.post("next") { ytClient(client, setLogin = true) setBody( NextBody( context = client.toContext(locale, visitorData, dataSyncId), videoId = videoId, playlistId = playlistId, playlistSetVideoId = playlistSetVideoId, index = index, params = params, continuation = continuation ) ) } } suspend fun feedback( client: YouTubeClient, tokens: List ) = withRetry { httpClient.post("feedback") { ytClient(client, setLogin = true) setBody( FeedbackBody( context = client.toContext(locale, visitorData, dataSyncId), feedbackTokens = tokens ) ) } } suspend fun getSearchSuggestions( client: YouTubeClient, input: String, ) = withRetry { httpClient.post("music/get_search_suggestions") { ytClient(client) setBody( GetSearchSuggestionsBody( context = client.toContext(locale, visitorData, null), input = input ) ) } } suspend fun getQueue( client: YouTubeClient, videoIds: List?, playlistId: String?, ) = withRetry { httpClient.post("music/get_queue") { ytClient(client) setBody( GetQueueBody( context = client.toContext(locale, visitorData, null), videoIds = videoIds, playlistId = playlistId ) ) } } suspend fun getTranscript( client: YouTubeClient, videoId: String, ) = withRetry { httpClient.post("https://music.youtube.com/youtubei/v1/get_transcript") { parameter("key", "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX3") headers { append("Content-Type", "application/json") } setBody( GetTranscriptBody( context = client.toContext(locale, null, null), params = Base64.Default.encode( "\n${11.toChar()}$videoId".encodeToByteArray() ) ) ) } } suspend fun getSwJsData() = withRetry { httpClient.get("https://music.youtube.com/sw.js_data") } suspend fun accountMenu(client: YouTubeClient) = withRetry { httpClient.post("account/account_menu") { ytClient(client, setLogin = true) setBody(AccountMenuBody(client.toContext(locale, visitorData, dataSyncId))) } } suspend fun likeVideo( client: YouTubeClient, videoId: String, ) = withRetry { httpClient.post("like/like") { ytClient(client, setLogin = true) setBody( LikeBody( context = client.toContext(locale, visitorData, dataSyncId), target = LikeBody.Target.video(videoId) ) ) } } suspend fun unlikeVideo( client: YouTubeClient, videoId: String, ) = withRetry { httpClient.post("like/removelike") { ytClient(client, setLogin = true) setBody( LikeBody( context = client.toContext(locale, visitorData, dataSyncId), target = LikeBody.Target.video(videoId) ) ) } } suspend fun subscribeChannel( client: YouTubeClient, channelId: String, params: String? = null, ) = withRetry { httpClient.post("subscription/subscribe") { ytClient(client, setLogin = true) setBody( SubscribeBody( context = client.toContext(locale, visitorData, dataSyncId), channelIds = listOf(channelId), params = params ) ) } } suspend fun unsubscribeChannel( client: YouTubeClient, channelId: String, params: String? = null, ) = withRetry { httpClient.post("subscription/unsubscribe") { ytClient(client, setLogin = true) setBody( SubscribeBody( context = client.toContext(locale, visitorData, dataSyncId), channelIds = listOf(channelId), params = params ) ) } } suspend fun likePlaylist( client: YouTubeClient, playlistId: String, ) = withRetry { httpClient.post("like/like") { ytClient(client, setLogin = true) setBody( LikeBody( context = client.toContext(locale, visitorData, dataSyncId), target = LikeBody.Target.playlist(playlistId) ) ) } } suspend fun unlikePlaylist( client: YouTubeClient, playlistId: String, ) = withRetry { httpClient.post("like/removelike") { ytClient(client, setLogin = true) setBody( LikeBody( context = client.toContext(locale, visitorData, dataSyncId), target = LikeBody.Target.playlist(playlistId) ) ) } } suspend fun addToPlaylist( client: YouTubeClient, playlistId: String, videoId: String, ) = withRetry { httpClient.post("browse/edit_playlist") { ytClient(client, setLogin = true) setBody( EditPlaylistBody( context = client.toContext(locale, visitorData, dataSyncId), playlistId = playlistId.removePrefix("VL"), actions = listOf( Action.AddVideoAction(addedVideoId = videoId) ) ) ) } } suspend fun addPlaylistToPlaylist( client: YouTubeClient, playlistId: String, addPlaylistId: String, ) = withRetry { httpClient.post("browse/edit_playlist") { ytClient(client, setLogin = true) setBody( EditPlaylistBody( context = client.toContext(locale, visitorData, dataSyncId), playlistId = playlistId.removePrefix("VL"), actions = listOf( Action.AddPlaylistAction(addedFullListId = addPlaylistId) ) ) ) } } suspend fun removeFromPlaylist( client: YouTubeClient, playlistId: String, videoId: String, setVideoId: String, ) = withRetry { httpClient.post("browse/edit_playlist") { ytClient(client, setLogin = true) setBody( EditPlaylistBody( context = client.toContext(locale, visitorData, dataSyncId), playlistId = playlistId.removePrefix("VL"), actions = listOf( Action.RemoveVideoAction( removedVideoId = videoId, setVideoId = setVideoId, ) ) ) ) } } suspend fun moveSongPlaylist( client: YouTubeClient, playlistId: String, setVideoId: String, successorSetVideoId: String?, ) = withRetry { httpClient.post("browse/edit_playlist") { ytClient(client, setLogin = true) setBody( EditPlaylistBody( context = client.toContext(locale, visitorData, dataSyncId), playlistId = playlistId, actions = listOf( Action.MoveVideoAction( movedSetVideoIdSuccessor = successorSetVideoId, setVideoId = setVideoId, ) ) ) ) } } suspend fun createPlaylist( client: YouTubeClient, title: String, ) = withRetry { httpClient.post("playlist/create") { ytClient(client, true) setBody( CreatePlaylistBody( context = client.toContext(locale, visitorData, dataSyncId), title = title ) ) } } suspend fun renamePlaylist( client: YouTubeClient, playlistId: String, name: String, ) = withRetry { httpClient.post("browse/edit_playlist") { ytClient(client, setLogin = true) setBody( EditPlaylistBody( context = client.toContext(locale, visitorData, dataSyncId), playlistId = playlistId, actions = listOf( Action.RenamePlaylistAction( playlistName = name ) ) ) ) } } suspend fun getUploadCustomThumbnailLink( client: YouTubeClient, contentLength: Int ) = withRetry { httpClient.post("https://music.youtube.com/playlist_image_upload/playlist_custom_thumbnail") { ytClient(client, setLogin = true) headers { append("X-Goog-Upload-Command", "start") append("X-Goog-Upload-Protocol", "resumable") append("X-Goog-Upload-Header-Content-Length", contentLength.toString()) } } } suspend fun uploadCustomThumbnail( client: YouTubeClient, uploadId: String, image: ByteArray, ) = withRetry { httpClient.post("https://music.youtube.com/playlist_image_upload/playlist_custom_thumbnail") { ytClient(client, setLogin = true) parameter("upload_id", uploadId) parameter("upload_protocol", "resumable") headers { append("X-Goog-Upload-Command", "upload, finalize") append("X-Goog-Upload-Offset", "0") } setBody(image) } } suspend fun setThumbnailPlaylist( client: YouTubeClient, playlistId: String, blobId: String, ) = withRetry { httpClient.post("browse/edit_playlist") { ytClient(client, setLogin = true) setBody( EditPlaylistBody( context = client.toContext(locale, visitorData, dataSyncId), playlistId = playlistId, actions = listOf( Action.SetCustomThumbnailAction( addedCustomThumbnail = Action.SetCustomThumbnailAction.AddedCustomThumbnail( playlistScottyEncryptedBlobId = blobId ) ) ) ) ) } } suspend fun removeThumbnailPlaylist( client: YouTubeClient, playlistId: String ) = withRetry { httpClient.post("browse/edit_playlist") { ytClient(client, setLogin = true) setBody( EditPlaylistBody( context = client.toContext(locale, visitorData, dataSyncId), playlistId = playlistId, actions = listOf( Action.RemoveCustomThumbnailAction() ) ) ) } } suspend fun deletePlaylist( client: YouTubeClient, playlistId: String, ) = withRetry { httpClient.post("playlist/delete") { println("deleting $playlistId") ytClient(client, setLogin = true) setBody( PlaylistDeleteBody( context = client.toContext(locale, visitorData, dataSyncId), playlistId = playlistId ) ) } } private suspend fun returnYouTubeDislike(videoId: String) = withRetry { httpClient.get("https://returnyoutubedislikeapi.com/Votes?videoId=$videoId") { contentType(ContentType.Application.Json) } } /** * Initialize a song upload to YouTube Music. * Returns the upload URL in the X-Goog-Upload-URL header. */ suspend fun initSongUpload( filename: String, contentLength: Long ) = withRetry { val authUser = "0" httpClient.post("https://upload.youtube.com/upload/usermusic/http?authuser=$authUser") { headers { append("X-Goog-Upload-Command", "start") append("X-Goog-Upload-Protocol", "resumable") append("X-Goog-Upload-Header-Content-Length", contentLength.toString()) append("X-Goog-AuthUser", authUser) append("Origin", YouTubeClient.ORIGIN_YOUTUBE_MUSIC) cookie?.let { cookie -> append("cookie", cookie) if ("SAPISID" !in cookieMap) return@let val currentTime = System.currentTimeMillis() / 1000 val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} ${YouTubeClient.ORIGIN_YOUTUBE_MUSIC}") append("Authorization", "SAPISIDHASH ${currentTime}_${sapisidHash}") } } contentType(ContentType.Application.FormUrlEncoded) setBody("filename=$filename") } } /** * Upload song data to the provided upload URL. */ suspend fun uploadSongData( uploadUrl: String, data: ByteArray, onProgress: ((Float) -> Unit)? = null ) = withRetry { httpClient.post(uploadUrl) { headers { append("X-Goog-Upload-Command", "upload, finalize") append("X-Goog-Upload-Offset", "0") append("X-Goog-AuthUser", "0") append("Origin", YouTubeClient.ORIGIN_YOUTUBE_MUSIC) cookie?.let { cookie -> append("cookie", cookie) if ("SAPISID" !in cookieMap) return@let val currentTime = System.currentTimeMillis() / 1000 val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} ${YouTubeClient.ORIGIN_YOUTUBE_MUSIC}") append("Authorization", "SAPISIDHASH ${currentTime}_${sapisidHash}") } } contentType(ContentType.Application.FormUrlEncoded) setBody(data) onUpload { bytesSentTotal, contentLength -> contentLength?.let { onProgress?.invoke(bytesSentTotal.toFloat() / it.toFloat()) } } } } /** * Delete a privately owned (uploaded) song from YouTube Music. */ suspend fun deletePrivatelyOwnedEntity(entityId: String) = withRetry { val context = YouTubeClient.WEB_REMIX.toContext(locale, visitorData, null) val requestBody = """{"context":${Json.encodeToString(context)},"entityId":"$entityId"}""" httpClient.post("https://music.youtube.com/youtubei/v1/music/delete_privately_owned_entity") { contentType(ContentType.Application.Json) headers { append("Referer", YouTubeClient.REFERER_YOUTUBE_MUSIC) append("Origin", YouTubeClient.ORIGIN_YOUTUBE_MUSIC) cookie?.let { cookie -> append("cookie", cookie) if ("SAPISID" !in cookieMap) return@let val currentTime = System.currentTimeMillis() / 1000 val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} ${YouTubeClient.ORIGIN_YOUTUBE_MUSIC}") append("Authorization", "SAPISIDHASH ${currentTime}_${sapisidHash}") } } parameter("key", "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX3") parameter("prettyPrint", false) setBody(requestBody) } } suspend fun getMediaInfo(videoId: String): Result = runCatching { val response = next(client = YouTubeClient.WEB, videoId, null, null, null, null, null).body() val baseForInfo = response.contents.twoColumnWatchNextResults ?.results ?.results ?.content ?.find { it?.videoSecondaryInfoRenderer != null }?.videoSecondaryInfoRenderer val baseForTitle = response.contents.twoColumnWatchNextResults ?.results ?.results ?.content ?.find { it?.videoPrimaryInfoRenderer != null }?.videoPrimaryInfoRenderer val returnYouTubeDislikeResponse = returnYouTubeDislike(videoId).body() return@runCatching MediaInfo( videoId = videoId, title = baseForTitle ?.title ?.runs ?.firstOrNull() ?.text, author = baseForInfo ?.owner ?.videoOwnerRenderer ?.title ?.runs ?.firstOrNull() ?.text, authorId = baseForInfo ?.owner ?.videoOwnerRenderer ?.navigationEndpoint ?.browseEndpoint ?.browseId, authorThumbnail = baseForInfo ?.owner ?.videoOwnerRenderer ?.thumbnail ?.thumbnails ?.find { it.height == 48 }?.url ?.replace("s48", "s960"), description = baseForInfo?.attributedDescription?.content, subscribers = baseForInfo ?.owner ?.videoOwnerRenderer ?.subscriberCountText ?.simpleText?.split(" ")?.firstOrNull(), uploadDate = baseForTitle?.dateText?.simpleText, viewCount = returnYouTubeDislikeResponse.viewCount, like = returnYouTubeDislikeResponse.likes, dislike = returnYouTubeDislikeResponse.dislikes, ) } } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/NetworkConfig.kt ================================================ package com.metrolist.innertube import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import java.io.File import java.util.concurrent.TimeUnit /** * Enhanced network configuration for better performance and reliability * Inspired by ArchiveTune optimizations */ object NetworkConfig { // Timeout settings private const val CONNECT_TIMEOUT_SECONDS = 30L private const val READ_TIMEOUT_SECONDS = 60L private const val WRITE_TIMEOUT_SECONDS = 60L private const val REQUEST_TIMEOUT_MILLIS = 60000L // Cache settings private const val CACHE_SIZE_MB = 50L * 1024L * 1024L // 50 MB @OptIn(ExperimentalSerializationApi::class) fun createOptimizedHttpClient( cacheDir: File? = null, enableCache: Boolean = true ): HttpClient = HttpClient(OkHttp) { expectSuccess = true install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true explicitNulls = false encodeDefaults = true isLenient = true }) } install(ContentEncoding) { gzip(0.9F) deflate(0.8F) } install(HttpTimeout) { requestTimeoutMillis = REQUEST_TIMEOUT_MILLIS connectTimeoutMillis = CONNECT_TIMEOUT_SECONDS * 1000 socketTimeoutMillis = READ_TIMEOUT_SECONDS * 1000 } engine { config { // Timeout configurations connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) // Retry configuration retryOnConnectionFailure(true) // Cache configuration if (enableCache) { val cacheDirectory = cacheDir ?: File(System.getProperty("java.io.tmpdir"), "metrolist_http_cache") cache(okhttp3.Cache(cacheDirectory, CACHE_SIZE_MB)) } } } } /** * Create a client specifically optimized for YouTube Music API */ @OptIn(ExperimentalSerializationApi::class) fun createYouTubeMusicClient( cacheDir: File? = null ): HttpClient { val baseClient = createOptimizedHttpClient(cacheDir) return baseClient.config { // Additional configuration can be added here if needed } } /** * Network quality detection and adaptive configuration */ fun getAdaptiveTimeouts(networkQuality: NetworkQuality): TimeoutConfig { return when (networkQuality) { NetworkQuality.EXCELLENT -> TimeoutConfig( connectTimeout = 10000L, readTimeout = 30000L, requestTimeout = 45000L ) NetworkQuality.GOOD -> TimeoutConfig( connectTimeout = 20000L, readTimeout = 45000L, requestTimeout = 60000L ) NetworkQuality.POOR -> TimeoutConfig( connectTimeout = 30000L, readTimeout = 60000L, requestTimeout = 90000L ) NetworkQuality.UNKNOWN -> TimeoutConfig( connectTimeout = CONNECT_TIMEOUT_SECONDS * 1000, readTimeout = READ_TIMEOUT_SECONDS * 1000, requestTimeout = REQUEST_TIMEOUT_MILLIS ) } } enum class NetworkQuality { EXCELLENT, GOOD, POOR, UNKNOWN } data class TimeoutConfig( val connectTimeout: Long, val readTimeout: Long, val requestTimeout: Long ) } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/YouTube.kt ================================================ package com.metrolist.innertube import com.metrolist.innertube.models.AccountInfo import com.metrolist.innertube.models.YTItem import com.metrolist.innertube.models.AlbumItem import com.metrolist.innertube.models.Artist import com.metrolist.innertube.models.ArtistItem import com.metrolist.innertube.models.BrowseEndpoint import com.metrolist.innertube.models.GridRenderer import com.metrolist.innertube.models.MediaInfo import com.metrolist.innertube.models.MusicResponsiveListItemRenderer import com.metrolist.innertube.models.MusicTwoRowItemRenderer import com.metrolist.innertube.models.MusicCarouselShelfRenderer import com.metrolist.innertube.models.MusicShelfRenderer import com.metrolist.innertube.models.SectionListRenderer import com.metrolist.innertube.models.PlaylistItem import com.metrolist.innertube.models.PodcastItem import com.metrolist.innertube.models.EpisodeItem import com.metrolist.innertube.models.SearchSuggestions import com.metrolist.innertube.models.Run import com.metrolist.innertube.models.Runs import com.metrolist.innertube.models.SongItem import com.metrolist.innertube.models.TasteArtist import com.metrolist.innertube.models.TasteProfile import com.metrolist.innertube.models.WatchEndpoint import com.metrolist.innertube.models.WatchEndpoint.WatchEndpointMusicSupportedConfigs.WatchEndpointMusicConfig.Companion.MUSIC_VIDEO_TYPE_ATV import com.metrolist.innertube.models.YouTubeClient import com.metrolist.innertube.models.YouTubeClient.Companion.WEB import com.metrolist.innertube.models.YouTubeClient.Companion.WEB_REMIX import com.metrolist.innertube.models.YouTubeLocale import com.metrolist.innertube.models.getContinuation import com.metrolist.innertube.models.getItems import com.metrolist.innertube.models.oddElements import com.metrolist.innertube.models.response.AccountMenuResponse import com.metrolist.innertube.models.response.BrowseResponse import com.metrolist.innertube.models.response.CreatePlaylistResponse import com.metrolist.innertube.models.response.EditPlaylistResponse import com.metrolist.innertube.models.response.FeedbackResponse import com.metrolist.innertube.models.response.GetQueueResponse import com.metrolist.innertube.models.response.GetSearchSuggestionsResponse import com.metrolist.innertube.models.response.GetTranscriptResponse import com.metrolist.innertube.models.response.ImageUploadResponse import com.metrolist.innertube.models.response.NextResponse import com.metrolist.innertube.models.response.PlayerResponse import com.metrolist.innertube.models.response.SearchResponse import com.metrolist.innertube.pages.AlbumPage import com.metrolist.innertube.pages.ArtistItemsContinuationPage import com.metrolist.innertube.pages.ArtistItemsPage import com.metrolist.innertube.pages.ArtistPage import com.metrolist.innertube.pages.ChartsPage import com.metrolist.innertube.pages.BrowseResult import com.metrolist.innertube.pages.ExplorePage import com.metrolist.innertube.pages.HistoryPage import com.metrolist.innertube.pages.HomePage import com.metrolist.innertube.pages.LibraryContinuationPage import com.metrolist.innertube.pages.LibraryPage import com.metrolist.innertube.pages.MoodAndGenres import com.metrolist.innertube.pages.NewReleaseAlbumPage import com.metrolist.innertube.pages.NextPage import com.metrolist.innertube.pages.NextResult import com.metrolist.innertube.pages.PageHelper import com.metrolist.innertube.pages.PlaylistContinuationPage import com.metrolist.innertube.pages.PlaylistPage import com.metrolist.innertube.pages.PodcastPage import com.metrolist.innertube.pages.RelatedPage import com.metrolist.innertube.pages.SearchPage import com.metrolist.innertube.pages.SearchResult import com.metrolist.innertube.pages.SearchSuggestionPage import com.metrolist.innertube.pages.SearchSummary import com.metrolist.innertube.pages.SearchSummaryPage import io.ktor.client.call.body import io.ktor.client.statement.bodyAsText import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive import java.net.Proxy import kotlin.random.Random import timber.log.Timber /** * Parse useful data with [InnerTube] sending requests. * Modified from [ViMusic](https://github.com/vfsfitvnm/ViMusic) */ object YouTube { private val innerTube = InnerTube() var locale: YouTubeLocale get() = innerTube.locale set(value) { innerTube.locale = value } var visitorData: String? get() = innerTube.visitorData set(value) { innerTube.visitorData = value } var dataSyncId: String? get() = innerTube.dataSyncId set(value) { innerTube.dataSyncId = value } var cookie: String? get() = innerTube.cookie set(value) { innerTube.cookie = value } var proxy: Proxy? get() = innerTube.proxy set(value) { innerTube.proxy = value } var proxyAuth: String? get() = innerTube.proxyAuth set(value) { innerTube.proxyAuth = value } var useLoginForBrowse: Boolean get() = innerTube.useLoginForBrowse set(value) { innerTube.useLoginForBrowse = value } suspend fun searchSuggestions(query: String): Result = runCatching { val response = innerTube.getSearchSuggestions(WEB_REMIX, query).body() SearchSuggestions( queries = response.contents?.getOrNull(0)?.searchSuggestionsSectionRenderer?.contents?.mapNotNull { content -> content.searchSuggestionRenderer?.suggestion?.runs?.joinToString(separator = "") { it.text } }.orEmpty(), recommendedItems = response.contents?.getOrNull(1)?.searchSuggestionsSectionRenderer?.contents?.mapNotNull { it.musicResponsiveListItemRenderer?.let { renderer -> SearchSuggestionPage.fromMusicResponsiveListItemRenderer(renderer) } }.orEmpty() ) } suspend fun searchSummary(query: String): Result = runCatching { val response = innerTube.search(WEB_REMIX, query).body() val allSummaries = mutableListOf() response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents?.forEach { section -> if (section.musicCardShelfRenderer != null) { // Top result card - keep as single section val items = listOfNotNull(SearchSummaryPage.fromMusicCardShelfRenderer(section.musicCardShelfRenderer)) .plus( section.musicCardShelfRenderer.contents ?.mapNotNull { it.musicResponsiveListItemRenderer } ?.mapNotNull(SearchSummaryPage.Companion::fromMusicResponsiveListItemRenderer) .orEmpty() ) .distinctBy { it.id } if (items.isNotEmpty()) { allSummaries.add(SearchSummary( title = section.musicCardShelfRenderer.header?.musicCardShelfHeaderBasicRenderer?.title?.runs?.firstOrNull()?.text ?: YouTubeConstants.DEFAULT_TOP_RESULT, items = items )) } } else if (section.musicShelfRenderer != null) { val items = section.musicShelfRenderer.contents?.getItems() ?.mapNotNull { SearchSummaryPage.fromMusicResponsiveListItemRenderer(it) } ?.distinctBy { it.id } ?: emptyList() if (items.isEmpty()) return@forEach val apiTitle = section.musicShelfRenderer.title?.runs?.firstOrNull()?.text if (apiTitle != null) { // API provided a title, use single section allSummaries.add(SearchSummary(title = apiTitle, items = items)) } else { // No title - group items by type into separate sections val grouped = items.groupBy { item -> when (item) { is EpisodeItem -> "Episodes" is PodcastItem -> "Podcasts" is AlbumItem -> "Albums" is ArtistItem -> if (item.isProfile) "Profiles" else "Artists" is PlaylistItem -> "Playlists" is SongItem -> when { item.isEpisode -> "Episodes" item.isVideoSong -> "Videos" else -> "Songs" } } } // Add each group as a separate section in a logical order val sectionOrder = listOf("Songs", "Videos", "Albums", "Artists", "Playlists", "Podcasts", "Episodes", "Profiles", YouTubeConstants.DEFAULT_OTHER_RESULTS) sectionOrder.forEach { sectionName -> grouped[sectionName]?.let { groupItems -> if (groupItems.isNotEmpty()) { allSummaries.add(SearchSummary(title = sectionName, items = groupItems)) } } } } } } // Merge sections with the same title val mergedSummaries = allSummaries .groupBy { it.title } .map { (title, sections) -> SearchSummary( title = title, items = sections.flatMap { it.items }.distinctBy { it.id } ) } // Reorder to maintain logical order .sortedBy { summary -> when (summary.title) { YouTubeConstants.DEFAULT_TOP_RESULT -> 0 "Songs" -> 1 "Videos" -> 2 "Albums" -> 3 "Artists" -> 4 "Playlists" -> 5 "Podcasts" -> 6 "Episodes" -> 7 "Profiles" -> 8 else -> 9 } } SearchSummaryPage(summaries = mergedSummaries) } suspend fun search(query: String, filter: SearchFilter): Result = runCatching { val response = innerTube.search(WEB_REMIX, query, filter.value).body() val shelves = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents ?.mapNotNull { it.musicShelfRenderer } .orEmpty() SearchResult( items = shelves.flatMap { shelf -> shelf.contents?.getItems()?.mapNotNull { SearchPage.toYTItem(it) } ?: emptyList() }.distinctBy { it.id }, continuation = shelves.firstOrNull { it.continuations != null } ?.continuations?.getContinuation() ) } suspend fun searchContinuation(continuation: String): Result = runCatching { val response = innerTube.search(WEB_REMIX, continuation = continuation).body() val items = response.continuationContents?.musicShelfContinuation?.contents ?.mapNotNull { SearchPage.toYTItem(it.musicResponsiveListItemRenderer) } ?: emptyList() SearchResult( items = items, continuation = if (items.isEmpty()) null else response.continuationContents?.musicShelfContinuation?.continuations?.getContinuation() ) } suspend fun album(browseId: String, withSongs: Boolean = true): Result = runCatching { val response = innerTube.browse(WEB_REMIX, browseId).body() if (browseId.contains("FEmusic_library_privately_owned_release_detail")) { val playlistId = response.header?.musicDetailHeaderRenderer?.menu?.menuRenderer?.topLevelButtons?.firstOrNull()?.buttonRenderer?.navigationEndpoint?.watchPlaylistEndpoint?.playlistId!! val albumItem = AlbumItem( browseId = browseId, playlistId = playlistId, title = response.header.musicDetailHeaderRenderer.title.runs?.firstOrNull()?.text!!, artists = response.header.musicDetailHeaderRenderer.subtitle.runs?.filter { it.navigationEndpoint != null }?.map { Artist( name = it.text, id = it.navigationEndpoint?.browseEndpoint?.browseId ) }, year = response.header.musicDetailHeaderRenderer.subtitle.runs?.lastOrNull()?.text?.toIntOrNull(), thumbnail = response.header.musicDetailHeaderRenderer.thumbnail.croppedSquareThumbnailRenderer?.thumbnail?.thumbnails?.lastOrNull()!!.url, explicit = false, // TODO: Extract explicit badge for albums from YouTube response ) return@runCatching AlbumPage( album = albumItem, songs = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.musicShelfRenderer?.contents?.getItems()?.mapNotNull { AlbumPage.getSong(it, albumItem) }!!.toMutableList(), otherVersions = emptyList() ) } else { val playlistId = response.microformat?.microformatDataRenderer?.urlCanonical?.substringAfterLast('=')!! val albumItem = AlbumItem( browseId = browseId, playlistId = playlistId, title = response.contents?.twoColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.musicResponsiveHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, artists = response.contents.twoColumnBrowseResultsRenderer.tabs.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.musicResponsiveHeaderRenderer?.straplineTextOne?.runs?.oddElements() ?.map { Artist( name = it.text, id = it.navigationEndpoint?.browseEndpoint?.browseId ) }!!, year = response.contents.twoColumnBrowseResultsRenderer.tabs.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.musicResponsiveHeaderRenderer?.subtitle?.runs?.lastOrNull()?.text?.toIntOrNull(), thumbnail = response.contents.twoColumnBrowseResultsRenderer.tabs.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.musicResponsiveHeaderRenderer?.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.lastOrNull()?.url!!, explicit = false, // TODO: Extract explicit badge for albums from YouTube response ) return@runCatching AlbumPage( album = albumItem, songs = if (withSongs) albumSongs( playlistId, albumItem ).getOrThrow() else emptyList(), otherVersions = response.contents.twoColumnBrowseResultsRenderer.secondaryContents?.sectionListRenderer?.contents?.getOrNull( 1 )?.musicCarouselShelfRenderer?.contents ?.mapNotNull { it.musicTwoRowItemRenderer } ?.mapNotNull(NewReleaseAlbumPage::fromMusicTwoRowItemRenderer) .orEmpty() ) } } suspend fun albumSongs(playlistId: String, album: AlbumItem? = null): Result> = runCatching { var response = innerTube.browse(WEB_REMIX, "VL$playlistId").body() val songs = response.contents?.twoColumnBrowseResultsRenderer ?.secondaryContents?.sectionListRenderer ?.contents?.firstOrNull() ?.musicPlaylistShelfRenderer?.contents?.getItems() ?.mapNotNull { AlbumPage.getSong(it, album) }!! .toMutableList() var continuation = response.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer .contents.firstOrNull()?.musicPlaylistShelfRenderer?.contents?.getContinuation() val seenContinuations = mutableSetOf() var requestCount = 0 val maxRequests = 50 // Prevent excessive API calls while (continuation != null && requestCount < maxRequests) { // Prevent infinite loops by tracking seen continuations if (continuation in seenContinuations) { break } seenContinuations.add(continuation) requestCount++ response = innerTube.browse( client = WEB_REMIX, continuation = continuation, ).body() songs += response.onResponseReceivedActions?.firstOrNull()?.appendContinuationItemsAction?.continuationItems?.getItems()?.mapNotNull { AlbumPage.getSong(it, album) }.orEmpty() continuation = response.continuationContents?.musicPlaylistShelfContinuation?.continuations?.getContinuation() } songs } suspend fun artist(browseId: String): Result = runCatching { val response = innerTube.browse(WEB_REMIX, browseId).body() fun mapRuns(runs: List?): List? = runs?.map { run -> Run( text = run.text, navigationEndpoint = run.navigationEndpoint ) } val descriptionRuns = response.contents?.sectionListRenderer?.contents ?.firstOrNull { it.musicDescriptionShelfRenderer != null } ?.musicDescriptionShelfRenderer?.description?.runs ?.let(::mapRuns) ?: response.header?.musicImmersiveHeaderRenderer?.description?.runs?.let(::mapRuns) // Check subscription state from multiple locations: // 1. musicImmersiveHeaderRenderer.subscriptionButton (regular artists) // 2. musicVisualHeaderRenderer.subscriptionButton (podcast channels) val immersiveSubscribed = response.header?.musicImmersiveHeaderRenderer?.subscriptionButton?.subscribeButtonRenderer?.subscribed val visualSubscribed = response.header?.musicVisualHeaderRenderer?.subscriptionButton?.subscribeButtonRenderer?.subscribed val isSubscribed = immersiveSubscribed ?: visualSubscribed ?: false // Also extract channelId from visual header if not in immersive header val channelIdFromVisual = response.header?.musicVisualHeaderRenderer?.subscriptionButton?.subscribeButtonRenderer?.channelId ArtistPage( artist = ArtistItem( id = browseId, title = response.header?.musicImmersiveHeaderRenderer?.title?.runs?.firstOrNull()?.text ?: response.header?.musicVisualHeaderRenderer?.title?.runs?.firstOrNull()?.text ?: response.header?.musicHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, thumbnail = response.header?.musicImmersiveHeaderRenderer?.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: response.header?.musicVisualHeaderRenderer?.foregroundThumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: response.header?.musicDetailHeaderRenderer?.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl(), channelId = response.header?.musicImmersiveHeaderRenderer?.subscriptionButton?.subscribeButtonRenderer?.channelId ?: channelIdFromVisual, playEndpoint = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.musicShelfRenderer ?.contents?.firstOrNull()?.musicResponsiveListItemRenderer?.overlay?.musicItemThumbnailOverlayRenderer ?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchEndpoint, shuffleEndpoint = response.header?.musicImmersiveHeaderRenderer?.playButton?.buttonRenderer?.navigationEndpoint?.watchEndpoint ?: response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer ?.contents?.firstOrNull()?.musicShelfRenderer?.contents?.firstOrNull()?.musicResponsiveListItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint, radioEndpoint = response.header?.musicImmersiveHeaderRenderer?.startRadioButton?.buttonRenderer?.navigationEndpoint?.watchEndpoint ), sections = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents ?.mapNotNull(ArtistPage::fromSectionListRendererContent)!!, description = descriptionRuns?.joinToString(separator = "") { it.text }, subscriberCountText = response.header?.musicImmersiveHeaderRenderer?.subscriptionButton2 ?.subscribeButtonRenderer?.subscriberCountWithSubscribeText?.runs?.firstOrNull()?.text ?: response.header?.musicImmersiveHeaderRenderer?.subscriptionButton?.subscribeButtonRenderer ?.longSubscriberCountText?.runs?.firstOrNull()?.text ?: response.header?.musicImmersiveHeaderRenderer?.subscriptionButton?.subscribeButtonRenderer ?.shortSubscriberCountText?.runs?.firstOrNull()?.text, monthlyListenerCount = response.header?.musicImmersiveHeaderRenderer?.monthlyListenerCount?.runs?.firstOrNull()?.text, descriptionRuns = descriptionRuns, isSubscribed = isSubscribed ) } suspend fun artistItems(endpoint: BrowseEndpoint): Result = runCatching { val response = innerTube.browse(WEB_REMIX, endpoint.browseId, endpoint.params).body() val sectionContent = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() val gridRenderer = sectionContent?.gridRenderer val musicCarouselShelfRenderer = sectionContent?.musicCarouselShelfRenderer val musicPlaylistShelfRenderer = sectionContent?.musicPlaylistShelfRenderer val musicShelfRenderer = sectionContent?.musicShelfRenderer when { gridRenderer != null -> { ArtistItemsPage( title = gridRenderer.header?.gridHeaderRenderer?.title?.runs?.firstOrNull()?.text.orEmpty(), items = gridRenderer.items.mapNotNull { it.musicTwoRowItemRenderer?.let { renderer -> ArtistItemsPage.fromMusicTwoRowItemRenderer(renderer) } }, continuation = gridRenderer.continuations?.getContinuation() ) } musicCarouselShelfRenderer != null -> { ArtistItemsPage( title = musicCarouselShelfRenderer.header?.musicCarouselShelfBasicHeaderRenderer?.title?.runs?.firstOrNull()?.text.orEmpty(), items = musicCarouselShelfRenderer.contents.mapNotNull { content -> content.musicTwoRowItemRenderer?.let { renderer -> ArtistItemsPage.fromMusicTwoRowItemRenderer(renderer) } ?: content.musicResponsiveListItemRenderer?.let { renderer -> ArtistItemsPage.fromMusicResponsiveListItemRenderer(renderer) } }, continuation = null ) } musicShelfRenderer != null -> { ArtistItemsPage( title = musicShelfRenderer.title?.runs?.firstOrNull()?.text ?: response.header?.musicHeaderRenderer?.title?.runs?.firstOrNull()?.text ?: "", items = musicShelfRenderer.contents?.getItems()?.mapNotNull { ArtistItemsPage.fromMusicResponsiveListItemRenderer(it) } ?: emptyList(), continuation = musicShelfRenderer.continuations?.getContinuation() ) } else -> { ArtistItemsPage( title = response.header?.musicHeaderRenderer?.title?.runs?.firstOrNull()?.text ?: "", items = musicPlaylistShelfRenderer?.contents?.getItems()?.mapNotNull { ArtistItemsPage.fromMusicResponsiveListItemRenderer(it) } ?: emptyList(), continuation = musicPlaylistShelfRenderer?.contents?.getContinuation() ) } } } suspend fun artistItemsContinuation(continuation: String): Result = runCatching { val response = innerTube.browse(WEB_REMIX, continuation = continuation).body() when { response.continuationContents?.gridContinuation != null -> { val gridContinuation = response.continuationContents.gridContinuation val items = gridContinuation.items.mapNotNull { it.musicTwoRowItemRenderer?.let { renderer -> ArtistItemsPage.fromMusicTwoRowItemRenderer(renderer) } } ArtistItemsContinuationPage( items = items, continuation = if (items.isEmpty()) null else gridContinuation.continuations?.getContinuation() ) } response.continuationContents?.musicPlaylistShelfContinuation != null -> { val musicPlaylistShelfContinuation = response.continuationContents.musicPlaylistShelfContinuation val items = musicPlaylistShelfContinuation.contents.getItems().mapNotNull { ArtistItemsPage.fromMusicResponsiveListItemRenderer(it) } ArtistItemsContinuationPage( items = items, continuation = if (items.isEmpty()) null else musicPlaylistShelfContinuation.continuations?.getContinuation() ) } else -> { val continuationItems = response.onResponseReceivedActions?.firstOrNull() ?.appendContinuationItemsAction?.continuationItems val items = continuationItems?.getItems()?.mapNotNull { ArtistItemsPage.fromMusicResponsiveListItemRenderer(it) } ?: emptyList() ArtistItemsContinuationPage( items = items, continuation = if (items.isEmpty()) null else continuationItems?.getContinuation() ) } } } suspend fun playlist(playlistId: String): Result = runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = "VL$playlistId", setLogin = true ).body() val base = response.contents?.twoColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() val header = base?.musicResponsiveHeaderRenderer ?: base?.musicEditablePlaylistDetailHeaderRenderer?.header?.musicResponsiveHeaderRenderer val editable = base?.musicEditablePlaylistDetailHeaderRenderer != null PlaylistPage( playlist = PlaylistItem( id = playlistId, title = header?.title?.runs?.firstOrNull()?.text!!, author = header.straplineTextOne?.runs?.firstOrNull()?.let { Artist( name = it.text, id = it.navigationEndpoint?.browseEndpoint?.browseId ) }, songCountText = header.secondSubtitle?.runs?.firstOrNull()?.text, thumbnail = header.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.lastOrNull()?.url!!, playEndpoint = null, shuffleEndpoint = header.buttons.lastOrNull()?.menuRenderer?.items?.firstOrNull()?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint!!, radioEndpoint = header.buttons.getOrNull(2)?.menuRenderer?.items?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MIX" }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint, isEditable = editable ), songs = response.contents?.twoColumnBrowseResultsRenderer?.secondaryContents?.sectionListRenderer ?.contents?.firstOrNull()?.musicPlaylistShelfRenderer?.contents?.getItems()?.mapNotNull { PlaylistPage.fromMusicResponsiveListItemRenderer(it) } ?: emptyList(), songsContinuation = response.contents?.twoColumnBrowseResultsRenderer?.secondaryContents?.sectionListRenderer ?.contents?.firstOrNull()?.musicPlaylistShelfRenderer?.contents?.getContinuation() ?: response.contents?.twoColumnBrowseResultsRenderer?.secondaryContents?.sectionListRenderer ?.contents?.firstOrNull()?.musicPlaylistShelfRenderer?.continuations?.getContinuation(), continuation = response.contents?.twoColumnBrowseResultsRenderer?.secondaryContents?.sectionListRenderer ?.continuations?.getContinuation() ) } suspend fun playlistContinuation(continuation: String): Result = runCatching { val response = innerTube.browse( client = WEB_REMIX, continuation = continuation, setLogin = true ).body() val mainContents: List = response.continuationContents?.sectionListContinuation?.contents ?.mapNotNull { content: SectionListRenderer.Content -> content.musicPlaylistShelfRenderer?.contents } ?.flatten() ?: emptyList() val shelfContents: List = response.continuationContents?.musicPlaylistShelfContinuation?.contents ?: emptyList() val appendedContents: List = response.onResponseReceivedActions ?.firstOrNull() ?.appendContinuationItemsAction ?.continuationItems .orEmpty() val allContents = mainContents + shelfContents + appendedContents val songs = allContents .mapNotNull { content: MusicShelfRenderer.Content -> content.musicResponsiveListItemRenderer } .mapNotNull { renderer -> PlaylistPage.fromMusicResponsiveListItemRenderer(renderer) } val nextContinuation = if (songs.isEmpty()) null else { response.continuationContents ?.sectionListContinuation ?.continuations ?.getContinuation() ?: response.continuationContents ?.musicPlaylistShelfContinuation ?.continuations ?.getContinuation() ?: response.continuationContents ?.musicShelfContinuation ?.continuations ?.getContinuation() ?: response.onResponseReceivedActions ?.firstOrNull() ?.appendContinuationItemsAction ?.continuationItems ?.getContinuation() } PlaylistContinuationPage( songs = songs, continuation = nextContinuation ) } suspend fun podcast(podcastId: String): Result = podcastWithDebug(podcastId) { } suspend fun podcastWithDebug(podcastId: String, log: (String) -> Unit): Result = runCatching { Timber.d("Fetching podcast with ID: $podcastId") val response = innerTube.browse( client = WEB_REMIX, browseId = podcastId, setLogin = true ).body() Timber.d("Response received, twoColumnBrowseResultsRenderer: ${response.contents?.twoColumnBrowseResultsRenderer != null}") Timber.d("singleColumnBrowseResultsRenderer: ${response.contents?.singleColumnBrowseResultsRenderer != null}") // Try twoColumn first (standard layout) var header = response.contents?.twoColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() ?.musicResponsiveHeaderRenderer // Fallback to singleColumn layout if (header == null) { header = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() ?.musicResponsiveHeaderRenderer Timber.d("Using singleColumn layout, header found: ${header != null}") } Timber.d("Header title: ${header?.title?.runs?.firstOrNull()?.text}") // Debug: Log button structure header?.buttons?.forEachIndexed { i, button -> Timber.d("[PODCAST] Button[$i]: menuRenderer=${button.menuRenderer != null}, toggleButtonRenderer=${button.toggleButtonRenderer != null}, playButtonRenderer=${button.musicPlayButtonRenderer != null}") button.menuRenderer?.items?.forEachIndexed { j, item -> Timber.d("[PODCAST] Button[$i].menuItems[$j]: toggle=${item.toggleMenuServiceItemRenderer?.defaultIcon?.iconType}, nav=${item.menuNavigationItemRenderer?.icon?.iconType}") // Check for SUBSCRIBE button (like artists have) if (item.toggleMenuServiceItemRenderer?.defaultIcon?.iconType == "SUBSCRIBE") { val channelIds = item.toggleMenuServiceItemRenderer.defaultServiceEndpoint.subscribeEndpoint?.channelIds Timber.d("[PODCAST] Found SUBSCRIBE button! channelIds=$channelIds") } } button.toggleButtonRenderer?.let { toggle -> Timber.d("[PODCAST] Button[$i].toggleButtonRenderer: defaultIcon=${toggle.defaultIcon?.iconType}, defaultToken=${toggle.defaultServiceEndpoint?.feedbackEndpoint?.feedbackToken?.take(30)}, subscribeChannelIds=${toggle.defaultServiceEndpoint?.subscribeEndpoint?.channelIds}") } } // Extract channelId and subscription state for subscription (like artists) val subscribeToggle = header?.buttons?.flatMap { button -> button.menuRenderer?.items ?: emptyList() }?.find { it.toggleMenuServiceItemRenderer?.defaultIcon?.iconType == "SUBSCRIBE" }?.toggleMenuServiceItemRenderer val channelId = subscribeToggle?.defaultServiceEndpoint?.subscribeEndpoint?.channelIds?.firstOrNull() // isSelected indicates user is currently subscribed (toggle is in "toggled" state) val isChannelSubscribed = subscribeToggle?.isSelected == true Timber.d("[PODCAST] Extracted channelId for subscription: $channelId, isSubscribed: $isChannelSubscribed") // Extract library tokens from the header's menu buttons OR toggle buttons var libraryTokens = header?.buttons?.flatMap { button -> button.menuRenderer?.items ?: emptyList() }?.let { menuItems -> PageHelper.extractLibraryTokensFromMenuItems(menuItems) } // Also check for standalone toggle buttons (used by some podcasts) if (libraryTokens?.addToken == null && libraryTokens?.removeToken == null) { header?.buttons?.forEach { button -> button.toggleButtonRenderer?.let { toggle -> val iconType = toggle.defaultIcon?.iconType if (iconType != null && PageHelper.isLibraryIcon(iconType)) { val defaultToken = toggle.defaultServiceEndpoint?.feedbackEndpoint?.feedbackToken val toggledToken = toggle.toggledServiceEndpoint?.feedbackEndpoint?.feedbackToken libraryTokens = if (PageHelper.isAddLibraryIcon(iconType)) { // BOOKMARK_BORDER: default=add, toggled=remove PageHelper.LibraryFeedbackTokens(defaultToken, toggledToken) } else { // BOOKMARK: default=remove, toggled=add PageHelper.LibraryFeedbackTokens(toggledToken, defaultToken) } Timber.d("[PODCAST] Found toggle button with library tokens - add: ${libraryTokens.addToken != null}, remove: ${libraryTokens.removeToken != null}") } } } } Timber.d("[PODCAST] Library tokens - add: ${libraryTokens?.addToken != null}, remove: ${libraryTokens?.removeToken != null}") val podcastItem = PodcastItem( id = podcastId, title = header?.title?.runs?.firstOrNull()?.text ?: "", author = header?.straplineTextOne?.runs?.firstOrNull()?.let { Artist( name = it.text, id = it.navigationEndpoint?.browseEndpoint?.browseId ) }, episodeCountText = header?.secondSubtitle?.runs?.firstOrNull()?.text, thumbnail = header?.thumbnail?.musicThumbnailRenderer?.thumbnail?.thumbnails?.lastOrNull()?.url, playEndpoint = header?.buttons?.find { it.menuRenderer?.items?.firstOrNull()?.menuNavigationItemRenderer?.icon?.iconType == "PLAY_ARROW" }?.menuRenderer?.items?.firstOrNull()?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint, shuffleEndpoint = header?.buttons?.find { it.menuRenderer?.items?.any { item -> item.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" } == true }?.menuRenderer?.items?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" } ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint, libraryAddToken = libraryTokens?.addToken, libraryRemoveToken = libraryTokens?.removeToken, channelId = channelId, ) // Try twoColumn for episodes val secondaryContents = response.contents?.twoColumnBrowseResultsRenderer?.secondaryContents Timber.d("secondaryContents null: ${secondaryContents == null}") Timber.d("secondaryContents.sectionListRenderer null: ${secondaryContents?.sectionListRenderer == null}") Timber.d("sectionListRenderer.contents size: ${secondaryContents?.sectionListRenderer?.contents?.size ?: 0}") secondaryContents?.sectionListRenderer?.contents?.forEachIndexed { index, content -> Timber.d("Content[$index]: musicShelfRenderer=${content.musicShelfRenderer != null}, musicPlaylistShelfRenderer=${content.musicPlaylistShelfRenderer != null}, gridRenderer=${content.gridRenderer != null}") content.musicShelfRenderer?.let { shelf -> Timber.d("musicShelfRenderer.contents size: ${shelf.contents?.size ?: 0}") } content.musicPlaylistShelfRenderer?.let { shelf -> Timber.d("musicPlaylistShelfRenderer.contents size: ${shelf.contents.size}") } } var episodeContents = secondaryContents?.sectionListRenderer ?.contents?.firstOrNull()?.musicShelfRenderer?.contents // Try musicPlaylistShelfRenderer if (episodeContents == null) { episodeContents = secondaryContents?.sectionListRenderer ?.contents?.firstOrNull()?.musicPlaylistShelfRenderer?.contents Timber.d("Trying musicPlaylistShelfRenderer: ${episodeContents?.size ?: 0}") } // Fallback to singleColumn if (episodeContents == null) { episodeContents = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents ?.find { it.musicShelfRenderer != null }?.musicShelfRenderer?.contents Timber.d("Using singleColumn for episodes, found: ${episodeContents?.size ?: 0}") } Timber.d("Episode contents count: ${episodeContents?.size ?: 0}") // Get episodes from musicMultiRowListItemRenderer (used for podcasts) val multiRowItems = episodeContents?.mapNotNull { it.musicMultiRowListItemRenderer } ?: emptyList() Timber.d("multiRowItems count: ${multiRowItems.size}") multiRowItems.take(2).forEachIndexed { idx, renderer -> Timber.d("Episode[$idx] title: ${renderer.title?.runs?.firstOrNull()?.text}") Timber.d("Episode[$idx] subtitle: ${renderer.subtitle?.runs?.map { it.text }}") Timber.d("Episode[$idx] videoId: ${renderer.onTap?.watchEndpoint?.videoId}") Timber.d("Episode[$idx] thumbnail: ${renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl()}") } val episodes = multiRowItems.mapNotNull { renderer -> PodcastPage.fromMusicMultiRowListItemRenderer(renderer, podcastItem) } Timber.d("Parsed episodes: ${episodes.size}") PodcastPage( podcast = podcastItem, episodes = episodes, continuation = response.contents?.twoColumnBrowseResultsRenderer?.secondaryContents?.sectionListRenderer ?.contents?.firstOrNull()?.musicShelfRenderer?.continuations?.getContinuation() ?: response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents ?.find { it.musicShelfRenderer != null }?.musicShelfRenderer?.continuations?.getContinuation(), isChannelSubscribed = isChannelSubscribed, ) } suspend fun home(continuation: String? = null, params: String? = null): Result = runCatching { Timber.d("home() called with continuation=$continuation, params=$params") if (continuation != null) { return@runCatching homeContinuation(continuation).getOrThrow() } val response = innerTube.browse(WEB_REMIX, browseId = "FEmusic_home", params = params).body() Timber.d("home() response received") val continuation = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.continuations?.getContinuation() val sectionListRender = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer Timber.d("home() sectionListRender contents size: ${sectionListRender?.contents?.size ?: 0}") val carousels = sectionListRender?.contents?.mapNotNull { it.musicCarouselShelfRenderer } ?: emptyList() Timber.d("home() carousels count: ${carousels.size}") val sections = carousels.mapNotNull { HomePage.Section.fromMusicCarouselShelfRenderer(it) }.toMutableList() Timber.d("home() sections parsed: ${sections.size}") val chips = sectionListRender?.header?.chipCloudRenderer?.chips?.mapNotNull { HomePage.Chip.fromChipCloudChipRenderer(it) } Timber.d("home() chips: ${chips?.size ?: 0}") HomePage(chips, sections, continuation) } private suspend fun homeContinuation(continuation: String): Result = runCatching { val response = innerTube.browse(WEB_REMIX, continuation = continuation).body() val continuation = response.continuationContents?.sectionListContinuation?.continuations?.getContinuation() HomePage( null, response.continuationContents?.sectionListContinuation?.contents ?.mapNotNull { it.musicCarouselShelfRenderer } ?.mapNotNull { HomePage.Section.fromMusicCarouselShelfRenderer(it) }.orEmpty(), continuation ) } suspend fun explore(): Result = runCatching { val response = innerTube.browse(WEB_REMIX, browseId = "FEmusic_explore").body() ExplorePage( newReleaseAlbums = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.find { it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint?.browseId == "FEmusic_new_releases_albums" }?.musicCarouselShelfRenderer?.contents ?.mapNotNull { it.musicTwoRowItemRenderer } ?.mapNotNull(NewReleaseAlbumPage::fromMusicTwoRowItemRenderer).orEmpty(), moodAndGenres = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.find { it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint?.browseId == "FEmusic_moods_and_genres" }?.musicCarouselShelfRenderer?.contents ?.mapNotNull { it.musicNavigationButtonRenderer } ?.mapNotNull(MoodAndGenres.Companion::fromMusicNavigationButtonRenderer) .orEmpty() ) } suspend fun newReleaseAlbums(): Result> = runCatching { val response = innerTube.browse(WEB_REMIX, browseId = "FEmusic_new_releases_albums").body() response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.gridRenderer?.items ?.mapNotNull { it.musicTwoRowItemRenderer } ?.mapNotNull(NewReleaseAlbumPage::fromMusicTwoRowItemRenderer) .orEmpty() } suspend fun moodAndGenres(): Result> = runCatching { val response = innerTube.browse(WEB_REMIX, browseId = "FEmusic_moods_and_genres").body() response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents!! .mapNotNull(MoodAndGenres.Companion::fromSectionListRendererContent) } suspend fun browse(browseId: String, params: String?): Result = runCatching { // Use authentication for library endpoints val needsLogin = browseId.startsWith("FEmusic_library") || browseId == "VLSE" || browseId == "VLRDPN" val response = innerTube.browse(WEB_REMIX, browseId = browseId, params = params, setLogin = needsLogin).body() val sectionContents = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents BrowseResult( title = response.header?.musicHeaderRenderer?.title?.runs?.firstOrNull()?.text, items = sectionContents?.mapNotNull { content -> when { content.gridRenderer != null -> { BrowseResult.Item( title = content.gridRenderer.header?.gridHeaderRenderer?.title?.runs?.firstOrNull()?.text, items = content.gridRenderer.items .mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) .mapNotNull { renderer -> // Try LibraryPage first (more lenient for library endpoints), fall back to RelatedPage LibraryPage.fromMusicTwoRowItemRenderer(renderer) ?: RelatedPage.fromMusicTwoRowItemRenderer(renderer) } ) } content.musicCarouselShelfRenderer != null -> { BrowseResult.Item( title = content.musicCarouselShelfRenderer.header?.musicCarouselShelfBasicHeaderRenderer?.title?.runs?.firstOrNull()?.text, items = content.musicCarouselShelfRenderer.contents .mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) .mapNotNull { renderer -> LibraryPage.fromMusicTwoRowItemRenderer(renderer) ?: RelatedPage.fromMusicTwoRowItemRenderer(renderer) } ) } content.musicShelfRenderer != null -> { BrowseResult.Item( title = content.musicShelfRenderer.title?.runs?.firstOrNull()?.text, items = content.musicShelfRenderer.contents ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.mapNotNull(LibraryPage.Companion::fromMusicResponsiveListItemRenderer) ?: emptyList() ) } content.musicPlaylistShelfRenderer != null -> { BrowseResult.Item( title = null, // MusicPlaylistShelfRenderer doesn't have a title items = content.musicPlaylistShelfRenderer.contents.getItems() .mapNotNull(LibraryPage.Companion::fromMusicResponsiveListItemRenderer) ) } else -> null } }.orEmpty() ) } suspend fun library(browseId: String, tabIndex: Int = 0): Result { return runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = browseId, setLogin = true ).body() val tabs = response.contents?.singleColumnBrowseResultsRenderer?.tabs val contents = if (tabs != null && tabs.size > tabIndex) { tabs[tabIndex].tabRenderer.content?.sectionListRenderer?.contents?.firstOrNull() } else { null } when { contents?.gridRenderer != null -> { val gridItems = contents.gridRenderer.items val parsedItems = gridItems .mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) .mapNotNull { LibraryPage.fromMusicTwoRowItemRenderer(it) } LibraryPage( items = parsedItems, continuation = contents.gridRenderer.continuations?.getContinuation() ) } else -> { val shelfContents = contents?.musicShelfRenderer?.contents if (shelfContents == null) { throw IllegalStateException("No content found for browseId=$browseId") } val listItemRenderers = shelfContents.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) val parsedItems = listItemRenderers.mapNotNull { renderer -> LibraryPage.fromMusicResponsiveListItemRenderer(renderer) } LibraryPage( items = parsedItems, continuation = contents.musicShelfRenderer.continuations?.getContinuation() ) } } } } suspend fun libraryContinuation(continuation: String) = runCatching { val response = innerTube.browse( client = WEB_REMIX, continuation = continuation, setLogin = true ).body() val contents = response.continuationContents when { contents?.gridContinuation != null -> { LibraryContinuationPage( items = contents.gridContinuation.items .mapNotNull (GridRenderer.Item::musicTwoRowItemRenderer) .mapNotNull { LibraryPage.fromMusicTwoRowItemRenderer(it) }, continuation = contents.gridContinuation.continuations?.getContinuation() ) } else -> { // contents?.musicShelfContinuation != null LibraryContinuationPage( items = contents?.musicShelfContinuation?.contents!! .mapNotNull (MusicShelfRenderer.Content::musicResponsiveListItemRenderer) .mapNotNull { LibraryPage.fromMusicResponsiveListItemRenderer(it) }, continuation = contents.musicShelfContinuation.continuations?.getContinuation() ) } } } suspend fun libraryRecentActivity(): Result = runCatching { val continuation = LibraryFilter.FILTER_RECENT_ACTIVITY.value val response = innerTube.browse( client = WEB_REMIX, continuation = continuation, setLogin = true ).body() val gridItems = response.continuationContents?.sectionListContinuation?.contents?.firstOrNull() ?.gridRenderer?.items if (gridItems == null) { return@runCatching LibraryPage( items = emptyList(), continuation = null ) } val items = gridItems.mapNotNull { it.musicTwoRowItemRenderer?.let { renderer -> LibraryPage.fromMusicTwoRowItemRenderer(renderer) } }.toMutableList() /* * We need to fetch the artist page when accessing the library because it allows to have * a proper playEndpoint, which is needed to correctly report the playing indicator in * the home page. * * Despite this, we need to use the old thumbnail because it's the proper format for a * square picture, which is what we need. */ items.forEachIndexed { index, item -> if (item is ArtistItem) { artist(item.id).getOrNull()?.artist?.let { fetchedArtist -> items[index] = fetchedArtist.copy(thumbnail = item.thumbnail) } } } LibraryPage( items = items, continuation = null ) } suspend fun getChartsPage(continuation: String? = null): Result = runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = "FEmusic_charts", params = "ggMGCgQIgAQ%3D", continuation = continuation ).body() val sections = mutableListOf() response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents?.forEach { content -> content.musicCarouselShelfRenderer?.let { renderer -> val title = renderer.header?.musicCarouselShelfBasicHeaderRenderer?.title?.runs?.firstOrNull()?.text ?: return@forEach val items = renderer.contents.mapNotNull { item -> when { item.musicResponsiveListItemRenderer != null -> convertToChartItem(item.musicResponsiveListItemRenderer) item.musicTwoRowItemRenderer != null -> convertMusicTwoRowItem(item.musicTwoRowItemRenderer) else -> null } }.filterNotNull() if (items.isNotEmpty()) { sections.add( ChartsPage.ChartSection( title = title, items = items, chartType = determineChartType(title) ) ) } } content.gridRenderer?.let { renderer -> val title = renderer.header?.gridHeaderRenderer?.title?.runs?.firstOrNull()?.text ?: return@let val items = renderer.items.mapNotNull { item -> item.musicTwoRowItemRenderer?.let { renderer -> convertMusicTwoRowItem(renderer) } }.filterNotNull() if (items.isNotEmpty()) { sections.add( ChartsPage.ChartSection( title = title, items = items, chartType = ChartsPage.ChartType.NEW_RELEASES ) ) } } } ChartsPage( sections = sections, continuation = response.continuationContents?.sectionListContinuation?.continuations?.getContinuation() ) } private fun determineChartType(title: String): ChartsPage.ChartType { return when { title.contains("Trending", ignoreCase = true) -> ChartsPage.ChartType.TRENDING title.contains("Top", ignoreCase = true) -> ChartsPage.ChartType.TOP else -> ChartsPage.ChartType.GENRE } } private fun convertToChartItem(renderer: MusicResponsiveListItemRenderer): YTItem? { return try { when { renderer.flexColumns.size >= 3 && renderer.playlistItemData?.videoId != null -> { val firstColumn = renderer.flexColumns.getOrNull(0) ?.musicResponsiveListItemFlexColumnRenderer ?.text ?: return null val secondColumn = renderer.flexColumns.getOrNull(1) ?.musicResponsiveListItemFlexColumnRenderer ?.text ?: return null val titleRun = firstColumn.runs?.firstOrNull() ?: return null val title = titleRun.text.takeIf { it.isNotBlank() } ?: return null val artists = secondColumn.runs?.mapNotNull { run -> run.text.takeIf { it.isNotBlank() }?.let { name -> Artist( name = name, id = run.navigationEndpoint?.browseEndpoint?.browseId ) } } ?: emptyList() val thirdColumn = renderer.flexColumns.getOrNull(2) ?.musicResponsiveListItemFlexColumnRenderer ?.text SongItem( id = renderer.playlistItemData.videoId, title = title, artists = artists, thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, musicVideoType = renderer.musicVideoType, explicit = renderer.badges?.any { it.musicInlineBadgeRenderer?.icon?.iconType == "MUSIC_EXPLICIT_BADGE" } == true, chartPosition = thirdColumn?.runs?.firstOrNull()?.text?.toIntOrNull(), chartChange = thirdColumn?.runs?.getOrNull(1)?.text ) } else -> null } } catch (e: Exception) { println("Error converting chart item: ${e.message}\n${Json.encodeToString(renderer)}") null } } private fun convertMusicTwoRowItem(renderer: MusicTwoRowItemRenderer): YTItem? { return try { when { renderer.isSong -> { val subtitle = renderer.subtitle?.runs ?: return null SongItem( id = renderer.navigationEndpoint.watchEndpoint?.videoId ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, artists = subtitle.mapNotNull { it.navigationEndpoint?.browseEndpoint?.browseId?.let { id -> Artist(name = it.text, id = id) } }, thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, musicVideoType = renderer.musicVideoType, explicit = renderer.subtitleBadges?.any { it.musicInlineBadgeRenderer?.icon?.iconType == "MUSIC_EXPLICIT_BADGE" } == true ) } renderer.isAlbum -> { AlbumItem( browseId = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, playlistId = renderer.thumbnailOverlay?.musicItemThumbnailOverlayRenderer?.content ?.musicPlayButtonRenderer?.playNavigationEndpoint ?.watchPlaylistEndpoint?.playlistId ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, artists = renderer.subtitle?.runs?.oddElements()?.drop(1)?.mapNotNull { it.navigationEndpoint?.browseEndpoint?.browseId?.let { id -> Artist(name = it.text, id = id) } }, year = renderer.subtitle?.runs?.lastOrNull()?.text?.toIntOrNull(), thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, explicit = renderer.subtitleBadges?.any { it.musicInlineBadgeRenderer?.icon?.iconType == "MUSIC_EXPLICIT_BADGE" } == true ) } else -> null } } catch (e: Exception) { println("Error converting two row item: ${e.message}\n${Json.encodeToString(renderer)}") null } } suspend fun musicHistory() = runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = "FEmusic_history", setLogin = true ).body() HistoryPage( sections = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents ?.mapNotNull { it.musicShelfRenderer?.let { musicShelfRenderer -> HistoryPage.fromMusicShelfRenderer(musicShelfRenderer) } } ) } /** * Fetch podcast discovery/recommendations page. * Returns sections like "Popular shows", "Popular episodes", category sections. */ suspend fun podcastDiscover(): Result = runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = "FEmusic_non_music_audio", setLogin = true ).body() val sectionListRenderer = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer val carousels = sectionListRenderer?.contents?.mapNotNull { it.musicCarouselShelfRenderer } ?: emptyList() val sections = carousels.mapNotNull { HomePage.Section.fromMusicCarouselShelfRenderer(it) } val chips = sectionListRenderer?.header?.chipCloudRenderer?.chips?.mapNotNull { HomePage.Chip.fromChipCloudChipRenderer(it) } val continuation = sectionListRenderer?.continuations?.getContinuation() HomePage(chips, sections, continuation) } suspend fun likeVideo(videoId: String, like: Boolean) = runCatching { if (like) innerTube.likeVideo(WEB_REMIX, videoId) else innerTube.unlikeVideo(WEB_REMIX, videoId) } suspend fun likePlaylist(playlistId: String, like: Boolean) = runCatching { if (like) innerTube.likePlaylist(WEB_REMIX, playlistId) else innerTube.unlikePlaylist(WEB_REMIX, playlistId) } suspend fun subscribeChannel(channelId: String, subscribe: Boolean, params: String? = null) = runCatching { // Default params from YouTube Music API - required for subscription to work val subscribeParams = params ?: "EgIIAhgA" if (subscribe) innerTube.subscribeChannel(WEB_REMIX, channelId, subscribeParams) else innerTube.unsubscribeChannel(WEB_REMIX, channelId, subscribeParams) } /** * Save a podcast show to library. * Uses likePlaylist API. Podcast IDs are "MPSP". */ suspend fun savePodcast(podcastId: String, save: Boolean) = runCatching { val playlistId = podcastId.removePrefix("MPSP") Timber.d("[PODCAST_API] savePodcast: podcastId=$podcastId, playlistId=$playlistId, save=$save") if (save) innerTube.likePlaylist(WEB_REMIX, playlistId) else innerTube.unlikePlaylist(WEB_REMIX, playlistId) } /** * Add episode to "Episodes for Later" playlist (SE). */ suspend fun addEpisodeToSavedEpisodes(videoId: String) = runCatching { innerTube.addToPlaylist(WEB_REMIX, "SE", videoId) } /** * Remove episode from "Episodes for Later" playlist (SE). * Note: setVideoId is required for removal and must be obtained from the playlist response. */ suspend fun removeEpisodeFromSavedEpisodes(videoId: String, setVideoId: String) = runCatching { innerTube.removeFromPlaylist(WEB_REMIX, "SE", videoId, setVideoId) } suspend fun libraryPodcastChannels(): Result { Timber.d("[PODCAST_API] libraryPodcastChannels: calling browse with FEmusic_library_non_music_audio_channels_list") return runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = "FEmusic_library_non_music_audio_channels_list", setLogin = true ).body() val contentList = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents ?: emptyList() val items = contentList.flatMap { content -> when { content.gridRenderer != null -> { content.gridRenderer.items .mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) .mapNotNull { LibraryPage.fromMusicTwoRowItemRenderer(it) } } content.musicShelfRenderer != null -> { content.musicShelfRenderer.contents ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.mapNotNull { LibraryPage.fromMusicResponsiveListItemRenderer(it) } ?: emptyList() } content.musicCarouselShelfRenderer != null -> { content.musicCarouselShelfRenderer.contents .mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) .mapNotNull { LibraryPage.fromMusicTwoRowItemRenderer(it) } } else -> emptyList() } } LibraryPage( items = items, continuation = null ) }.also { result -> result.onFailure { e -> Timber.e(e, "[PODCAST_API] libraryPodcastChannels FAILED") } result.onSuccess { Timber.d("[PODCAST_API] libraryPodcastChannels SUCCESS: ${it.items.size} items") } } } suspend fun libraryPodcastEpisodes(): Result { Timber.d("[PODCAST_API] libraryPodcastEpisodes: calling browse with FEmusic_library_non_music_audio_list") return runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = "FEmusic_library_non_music_audio_list", setLogin = true ).body() val contents = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() val items = when { contents?.gridRenderer != null -> { contents.gridRenderer.items .mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) .mapNotNull { LibraryPage.fromMusicTwoRowItemRenderer(it) } } contents?.musicShelfRenderer != null -> { contents.musicShelfRenderer.contents ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) ?.mapNotNull { LibraryPage.fromMusicResponsiveListItemRenderer(it) } ?: emptyList() } else -> emptyList() } LibraryPage( items = items, continuation = null ) }.also { result -> result.onFailure { e -> Timber.e(e, "[PODCAST_API] libraryPodcastEpisodes FAILED") } result.onSuccess { Timber.d("[PODCAST_API] libraryPodcastEpisodes SUCCESS: ${it.items.size} items") } } } /** * Fetch saved podcast shows from library. * Uses FEmusic_library_non_music_audio_list and filters to only PodcastItem. */ suspend fun savedPodcastShows(): Result> = runCatching { val libraryPage = libraryPodcastEpisodes().getOrThrow() libraryPage.items.filterIsInstance() } /** * Fetch "New Episodes" auto-playlist (VLRDPN). * Returns new episodes from saved/subscribed podcasts. */ suspend fun newEpisodes(): Result> { Timber.d("[PODCAST_API] newEpisodes: calling browse with VLRDPN") return runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = "VLRDPN", setLogin = true ).body() response.contents?.twoColumnBrowseResultsRenderer?.secondaryContents?.sectionListRenderer ?.contents?.firstOrNull()?.musicShelfRenderer?.contents ?.mapNotNull { it.musicMultiRowListItemRenderer } ?.map { renderer -> SongItem( id = renderer.onTap?.watchEndpoint?.videoId ?: "", title = renderer.title?.runs?.firstOrNull()?.text ?: "", artists = renderer.subtitle?.runs?.mapNotNull { run -> run.navigationEndpoint?.browseEndpoint?.let { endpoint -> Artist(name = run.text, id = endpoint.browseId) } } ?: emptyList(), album = null, duration = null, thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: "", isEpisode = true, ) } ?: emptyList() }.also { result -> result.onFailure { e -> Timber.e(e, "[PODCAST_API] newEpisodes FAILED") } result.onSuccess { Timber.d("[PODCAST_API] newEpisodes SUCCESS: ${it.size} items") } } } /** * Fetch the RDPN "New Episodes" playlist info (title + thumbnail). * Uses the same VLRDPN browse call as [newEpisodes] but parses the header instead. * Falls back to the first episode thumbnail if no header thumbnail is found. */ suspend fun newEpisodesPlaylistInfo(): Result = runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = "VLRDPN", setLogin = true ).body() // Try all known header renderers in priority order val thumbnail: String? = response.header?.musicImmersiveHeaderRenderer?.thumbnail ?.musicThumbnailRenderer?.getThumbnailUrl() ?: response.header?.musicVisualHeaderRenderer?.thumbnail ?.musicThumbnailRenderer?.getThumbnailUrl() ?: response.header?.musicDetailHeaderRenderer?.thumbnail ?.croppedSquareThumbnailRenderer?.thumbnail?.thumbnails?.lastOrNull()?.url // Fall back: thumbnail of the first episode in the list ?: response.contents?.twoColumnBrowseResultsRenderer?.secondaryContents ?.sectionListRenderer?.contents?.firstOrNull() ?.musicShelfRenderer?.contents?.firstOrNull() ?.musicMultiRowListItemRenderer?.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() val title = response.header?.musicImmersiveHeaderRenderer?.title?.runs ?.joinToString("") { it.text } ?: response.header?.musicVisualHeaderRenderer?.title?.runs ?.joinToString("") { it.text } ?: "New Episodes" PlaylistItem( id = "RDPN", title = title, author = null, songCountText = null, thumbnail = thumbnail, playEndpoint = null, shuffleEndpoint = null, radioEndpoint = null, ) } /** * Fetch "Episodes for Later" playlist (VLSE). * Returns manually saved episodes. */ suspend fun episodesForLater(): Result> = runCatching { Timber.d("[PODCAST_API] episodesForLater: calling browse with VLSE") val response = innerTube.browse( client = WEB_REMIX, browseId = "VLSE", setLogin = true ).body() // VLSE uses musicPlaylistShelfRenderer, not musicShelfRenderer val contents = response.contents?.twoColumnBrowseResultsRenderer?.secondaryContents?.sectionListRenderer ?.contents?.firstOrNull() val shelfContents = contents?.musicPlaylistShelfRenderer?.contents ?: contents?.musicShelfRenderer?.contents // Parse musicResponsiveListItemRenderer (standard playlist format) shelfContents?.mapNotNull { it.musicResponsiveListItemRenderer } ?.mapNotNull { renderer -> val videoId = renderer.playlistItemData?.videoId ?: return@mapNotNull null val setVideoId = renderer.playlistItemData.playlistSetVideoId val title = renderer.flexColumns.firstOrNull() ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.text ?: return@mapNotNull null val artistRun = renderer.flexColumns.getOrNull(1) ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull() SongItem( id = videoId, title = title, artists = artistRun?.let { listOf(Artist(name = it.text, id = it.navigationEndpoint?.browseEndpoint?.browseId)) } ?: emptyList(), album = null, duration = null, thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: "", setVideoId = setVideoId, isEpisode = true, ) } ?: emptyList() } /** * Fetch "Continue Listening" / Resume Playback. * Returns partially played episodes for resumption. */ suspend fun continueListening(): Result> = runCatching { val response = innerTube.browse( client = WEB_REMIX, browseId = "FEmusic_listening_review", setLogin = true ).body() response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents ?.flatMap { section -> section.musicShelfRenderer?.contents?.mapNotNull { content -> content.musicResponsiveListItemRenderer?.let { renderer -> val videoId = renderer.playlistItemData?.videoId ?: return@mapNotNull null val title = renderer.flexColumns.firstOrNull() ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.text ?: return@mapNotNull null val artistRun = renderer.flexColumns.getOrNull(1) ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull() SongItem( id = videoId, title = title, artists = artistRun?.let { listOf(Artist(name = it.text, id = it.navigationEndpoint?.browseEndpoint?.browseId)) } ?: emptyList(), album = null, duration = null, thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: "", isEpisode = true, ) } } ?: emptyList() } ?: emptyList() } suspend fun getChannelId(browseId: String): String { artist(browseId).onSuccess { return it.artist.channelId ?: "" } return "" } suspend fun addToPlaylist(playlistId: String, videoId: String) = runCatching { innerTube.addToPlaylist(WEB_REMIX, playlistId, videoId) } suspend fun addPlaylistToPlaylist(playlistId: String, addPlaylistId: String) = runCatching { innerTube.addPlaylistToPlaylist(WEB_REMIX, playlistId, addPlaylistId) } suspend fun removeFromPlaylist(playlistId: String, videoId: String, setVideoId: String) = runCatching { innerTube.removeFromPlaylist(WEB_REMIX, playlistId, videoId, setVideoId) } suspend fun moveSongPlaylist(playlistId: String, setVideoId: String, successorSetVideoId: String?) = runCatching { innerTube.moveSongPlaylist(WEB_REMIX, playlistId, setVideoId, successorSetVideoId) } fun createPlaylist(title: String) = runBlocking { innerTube.createPlaylist(WEB_REMIX, title).body().playlistId } suspend fun renamePlaylist(playlistId: String, name: String) = runCatching { innerTube.renamePlaylist(WEB_REMIX, playlistId, name) } suspend fun uploadCustomThumbnailLink(playlistId: String, image: ByteArray) = runCatching { val uploadUrl = innerTube.getUploadCustomThumbnailLink(WEB_REMIX, image.size).headers["x-guploader-uploadid"] val blobReq = innerTube.uploadCustomThumbnail( WEB_REMIX, uploadUrl!!, image ) val blobId = Json.decodeFromString(blobReq.bodyAsText()).encryptedBlobId innerTube.setThumbnailPlaylist(WEB_REMIX, playlistId, blobId).body().newHeader?.musicEditablePlaylistDetailHeaderRenderer?.header?.musicResponsiveHeaderRenderer?.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() } suspend fun removeThumbnailPlaylist(playlistId: String) = runCatching { innerTube.removeThumbnailPlaylist(WEB_REMIX, playlistId).body().newHeader?.musicEditablePlaylistDetailHeaderRenderer?.header?.musicResponsiveHeaderRenderer?.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() } suspend fun deletePlaylist(playlistId: String) = runCatching { innerTube.deletePlaylist(WEB_REMIX, playlistId) } suspend fun player(videoId: String, playlistId: String? = null, client: YouTubeClient, signatureTimestamp: Int? = null, poToken: String? = null): Result = runCatching { innerTube.player(client, videoId, playlistId, signatureTimestamp, poToken).body() } suspend fun registerPlayback(playlistId: String? = null, playbackTracking: String) = runCatching { val cpn = (1..16).map { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"[Random.Default.nextInt( 0, 64 )] }.joinToString("") val playbackUrl = playbackTracking.replace( "https://s.youtube.com", "https://music.youtube.com", ) innerTube.registerPlayback( url = playbackUrl, playlistId = playlistId, cpn = cpn ) } suspend fun next(endpoint: WatchEndpoint, continuation: String? = null): Result = runCatching { val response = innerTube.next( WEB_REMIX, endpoint.videoId, endpoint.playlistId, endpoint.playlistSetVideoId, endpoint.index, endpoint.params, continuation).body() val playlistPanelRenderer = response.continuationContents?.playlistPanelContinuation ?: response.contents.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer ?.watchNextTabbedResultsRenderer?.tabs?.get(0)?.tabRenderer?.content?.musicQueueRenderer ?.content?.playlistPanelRenderer!! val title = response.contents.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer ?.watchNextTabbedResultsRenderer?.tabs?.get(0)?.tabRenderer?.content?.musicQueueRenderer ?.header?.musicQueueHeaderRenderer?.subtitle?.runs?.firstOrNull()?.text val items = playlistPanelRenderer.contents.mapNotNull { content -> content.playlistPanelVideoRenderer ?.let(NextPage::fromPlaylistPanelVideoRenderer) ?.let { it to content.playlistPanelVideoRenderer.selected } } val songs = items.map { it.first } val currentIndex = items.indexOfFirst { it.second }.takeIf { it != -1 } // load automix items playlistPanelRenderer.contents.lastOrNull()?.automixPreviewVideoRenderer?.content?.automixPlaylistVideoRenderer?.navigationEndpoint?.watchPlaylistEndpoint?.let { watchPlaylistEndpoint -> return@runCatching next(watchPlaylistEndpoint).getOrThrow().let { result -> result.copy( title = title, items = songs + result.items, lyricsEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer?.watchNextTabbedResultsRenderer?.tabs?.getOrNull(1)?.tabRenderer?.endpoint?.browseEndpoint, relatedEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer?.watchNextTabbedResultsRenderer?.tabs?.getOrNull(2)?.tabRenderer?.endpoint?.browseEndpoint, currentIndex = currentIndex, endpoint = watchPlaylistEndpoint ) } } NextResult( title = title, items = songs, currentIndex = currentIndex, lyricsEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer?.watchNextTabbedResultsRenderer?.tabs?.getOrNull(1)?.tabRenderer?.endpoint?.browseEndpoint, relatedEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer?.watchNextTabbedResultsRenderer?.tabs?.getOrNull(2)?.tabRenderer?.endpoint?.browseEndpoint, continuation = playlistPanelRenderer.continuations?.getContinuation(), endpoint = endpoint ) } suspend fun lyrics(endpoint: BrowseEndpoint): Result = runCatching { val response = innerTube.browse(WEB_REMIX, endpoint.browseId, endpoint.params).body() response.contents?.sectionListRenderer?.contents ?.firstOrNull { it.musicDescriptionShelfRenderer != null } ?.musicDescriptionShelfRenderer?.description?.runs ?.joinToString(separator = "") { it.text } } suspend fun related(endpoint: BrowseEndpoint): Result = runCatching { val response = innerTube.browse(WEB_REMIX, endpoint.browseId).body() val songs = mutableListOf() val albums = mutableListOf() val artists = mutableListOf() val playlists = mutableListOf() response.contents?.sectionListRenderer?.contents?.forEach { sectionContent -> sectionContent.musicCarouselShelfRenderer?.contents?.forEach { content -> when (val item = content.musicResponsiveListItemRenderer?.let(RelatedPage.Companion::fromMusicResponsiveListItemRenderer) ?: content.musicTwoRowItemRenderer?.let(RelatedPage.Companion::fromMusicTwoRowItemRenderer)) { is SongItem -> if (content.musicResponsiveListItemRenderer?.overlay ?.musicItemThumbnailOverlayRenderer?.content ?.musicPlayButtonRenderer?.playNavigationEndpoint ?.watchEndpoint?.watchEndpointMusicSupportedConfigs ?.watchEndpointMusicConfig?.musicVideoType == MUSIC_VIDEO_TYPE_ATV ) songs.add(item) is AlbumItem -> albums.add(item) is ArtistItem -> artists.add(item) is PlaylistItem -> playlists.add(item) is PodcastItem, is EpisodeItem -> {} null -> {} } } } RelatedPage(songs, albums, artists, playlists) } suspend fun queue(videoIds: List? = null, playlistId: String? = null): Result> = runCatching { if (videoIds != null) { assert(videoIds.size <= MAX_GET_QUEUE_SIZE) // Max video limit } innerTube.getQueue(WEB_REMIX, videoIds, playlistId).body().queueDatas .mapNotNull { it.content.playlistPanelVideoRenderer?.let { renderer -> NextPage.fromPlaylistPanelVideoRenderer(renderer) } } } suspend fun transcript(videoId: String): Result = runCatching { val response = innerTube.getTranscript(WEB, videoId).body() response.actions?.firstOrNull()?.updateEngagementPanelAction?.content?.transcriptRenderer?.body?.transcriptBodyRenderer?.cueGroups?.joinToString(separator = "\n") { group -> val time = group.transcriptCueGroupRenderer.cues[0].transcriptCueRenderer.startOffsetMs val text = group.transcriptCueGroupRenderer.cues[0].transcriptCueRenderer.cue.simpleText .trim('♪') .trim(' ') "[%02d:%02d.%03d]$text".format(time / 60000, (time / 1000) % 60, time % 1000) }!! } suspend fun visitorData(): Result = runCatching { Json.parseToJsonElement(innerTube.getSwJsData().bodyAsText().substring(5)) .jsonArray[0] .jsonArray[2] .jsonArray.first { (it as? JsonPrimitive)?.contentOrNull?.let { candidate -> VISITOR_DATA_REGEX.containsMatchIn(candidate) } ?: false } .jsonPrimitive.content } suspend fun accountInfo(): Result = runCatching { innerTube.accountMenu(WEB_REMIX).body() .actions[0].openPopupAction.popup.multiPageMenuRenderer .header?.activeAccountHeaderRenderer ?.toAccountInfo()!! } suspend fun feedback(tokens: List): Result = runCatching { innerTube.feedback(WEB_REMIX, tokens).body().feedbackResponses.all { it.isProcessed } } /** * Add a song to library by fetching fresh feedback tokens from the next endpoint * This is more reliable than using cached tokens which might be stale */ suspend fun addSongToLibrary(videoId: String): Result = runCatching { // Get fresh song data with menu tokens using next endpoint val nextResult = next(WatchEndpoint(videoId = videoId)).getOrThrow() val song = nextResult.items.find { it.id == videoId } ?: throw Exception("Song not found in next response") val addToken = song.libraryAddToken ?: throw Exception("Add to library token not available") feedback(listOf(addToken)).getOrThrow() } /** * Remove a song from library by fetching fresh feedback tokens from the next endpoint */ suspend fun removeSongFromLibrary(videoId: String): Result = runCatching { // Get fresh song data with menu tokens using next endpoint val nextResult = next(WatchEndpoint(videoId = videoId)).getOrThrow() val song = nextResult.items.find { it.id == videoId } ?: throw Exception("Song not found in next response") val removeToken = song.libraryRemoveToken ?: throw Exception("Remove from library token not available") feedback(listOf(removeToken)).getOrThrow() } /** * Toggle song library status - adds if not in library, removes if in library * Uses fresh tokens fetched from the API for reliability */ suspend fun toggleSongLibrary(videoId: String, addToLibrary: Boolean): Result = runCatching { if (addToLibrary) { addSongToLibrary(videoId).getOrThrow() } else { removeSongFromLibrary(videoId).getOrThrow() } } suspend fun getMediaInfo(videoId: String): Result = runCatching { return innerTube.getMediaInfo(videoId) } suspend fun getTasteProfile(): Result = runCatching { // Browse the taste builder page // Note: Full parsing requires additional model support for musicTastebuilderShelfRenderer // This returns an empty profile for now - can be enhanced when models are added innerTube.browse( client = WEB_REMIX, browseId = "FEmusic_tastebuilder", setLogin = true ).body() TasteProfile(artists = emptyMap()) } suspend fun setTasteProfile(selectedArtists: List, allArtists: Map): Result = runCatching { val selectedValues = selectedArtists.mapNotNull { allArtists[it]?.selectionValue } val impressionValues = allArtists.values.map { it.impressionValue } if (selectedValues.isNotEmpty()) { feedback(selectedValues + impressionValues).getOrThrow() } } suspend fun removeHistoryItems(feedbackTokens: List): Result = runCatching { feedback(feedbackTokens).getOrThrow() } @JvmInline value class SearchFilter(val value: String) { companion object { val FILTER_SONG = SearchFilter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D") val FILTER_VIDEO = SearchFilter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D") val FILTER_ALBUM = SearchFilter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D") val FILTER_ARTIST = SearchFilter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D") val FILTER_FEATURED_PLAYLIST = SearchFilter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D") val FILTER_COMMUNITY_PLAYLIST = SearchFilter("EgeKAQQoAEABagoQAxAEEAoQCRAF") val FILTER_PODCAST = SearchFilter("EgWKAQJQAWoKEAkQChAFEAMQBA%3D%3D") val FILTER_EPISODE = SearchFilter("EgWKAQJYAWoKEAkQChAFEAMQBA%3D%3D") val FILTER_PROFILE = SearchFilter("EgWKAQJYAWoSEAUQCRADEAQQEBAVEAoQDhAR") } } @JvmInline value class LibraryFilter(val value: String) { companion object { val FILTER_RECENT_ACTIVITY = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpYnJhcnlfbGFuZGluZxoQZ2dNR0tnUUlCaEFCb0FZQg%3D%3D") val FILTER_RECENTLY_PLAYED = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpYnJhcnlfbGFuZGluZxoQZ2dNR0tnUUlCUkFCb0FZQg%3D%3D") val FILTER_PLAYLISTS_ALPHABETICAL = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpa2VkX3BsYXlsaXN0cxoQZ2dNR0tnUUlBUkFBb0FZQg%3D%3D") val FILTER_PLAYLISTS_RECENTLY_SAVED = LibraryFilter("4qmFsgIrEhdGRW11c2ljX2xpa2VkX3BsYXlsaXN0cxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D") } } const val MAX_GET_QUEUE_SIZE = 1000 private val VISITOR_DATA_REGEX = Regex("^Cg[t|s]") fun getNewPipeStreamUrls(videoId: String): List> { return NewPipeExtractor.newPipePlayer(videoId) } suspend fun newPipePlayer( videoId: String, tempRes: PlayerResponse, ): PlayerResponse? { if (tempRes.playabilityStatus.status != "OK") { return null } val streamsList = getNewPipeStreamUrls(videoId) if (streamsList.isEmpty()) return null val decodedSigResponse = tempRes.copy( streamingData = tempRes.streamingData?.copy( formats = tempRes.streamingData.formats?.map { format -> format.copy( url = streamsList.find { it.first == format.itag }?.second ?: format.url, ) }, adaptiveFormats = tempRes.streamingData.adaptiveFormats.map { adaptiveFormat -> adaptiveFormat.copy( url = streamsList.find { it.first == adaptiveFormat.itag }?.second ?: adaptiveFormat.url, ) }, ), ) val urlList = ( decodedSigResponse.streamingData?.adaptiveFormats?.mapNotNull { it.url }?.toMutableList() ?: mutableListOf() ).apply { decodedSigResponse.streamingData?.formats?.mapNotNull { it.url }?.let { addAll(it) } } return if (urlList.isNotEmpty()) { decodedSigResponse } else { null } } /** * Upload a song to YouTube Music. * @param filename The name of the file * @param data The file data as ByteArray * @param onProgress Callback for upload progress (0.0 to 1.0) * @return true if upload succeeded */ suspend fun uploadSong( filename: String, data: ByteArray, onProgress: ((Float) -> Unit)? = null ): Result = runCatching { onProgress?.invoke(0f) // Step 1: Initialize upload (5% of progress) val initResponse = innerTube.initSongUpload(filename, data.size.toLong()) val uploadUrl = initResponse.headers["X-Goog-Upload-URL"] ?: throw Exception("Failed to get upload URL") onProgress?.invoke(0.05f) // Step 2: Upload file data (5% to 100% of progress) val uploadResponse = innerTube.uploadSongData( uploadUrl = uploadUrl, data = data, onProgress = { uploadProgress -> // Map upload progress (0-1) to overall progress (0.05-1.0) onProgress?.invoke(0.05f + uploadProgress * 0.95f) } ) val status = uploadResponse.headers["X-Goog-Upload-Status"] status == "final" } /** * Delete an uploaded song from YouTube Music library. * @param entityId The entity ID of the uploaded song (typically the video ID) * @return true if deletion succeeded */ suspend fun deleteUploadedSong(entityId: String): Result = runCatching { innerTube.deletePrivatelyOwnedEntity(entityId) true } /** * Supported file types for upload */ val SUPPORTED_UPLOAD_TYPES = listOf("mp3", "m4a", "wma", "flac", "ogg") /** * Maximum file size for upload (300MB) */ const val MAX_UPLOAD_SIZE = 314572800L } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/YouTubeConstants.kt ================================================ package com.metrolist.innertube object YouTubeConstants { const val DEFAULT_TOP_RESULT = "Top result" const val DEFAULT_OTHER_RESULTS = "Other" } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/AccountInfo.kt ================================================ package com.metrolist.innertube.models data class AccountInfo( val name: String, val email: String?, val channelHandle: String?, val thumbnailUrl: String?, ) ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/AutomixPreviewVideoRenderer.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.Serializable @Serializable data class AutomixPreviewVideoRenderer( val content: Content, ) { @Serializable data class Content( val automixPlaylistVideoRenderer: AutomixPlaylistVideoRenderer, ) { @Serializable data class AutomixPlaylistVideoRenderer( val navigationEndpoint: NavigationEndpoint, ) } } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/Badges.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.Serializable @Serializable data class Badges( val musicInlineBadgeRenderer: MusicInlineBadgeRenderer?, ) { @Serializable data class MusicInlineBadgeRenderer( val icon: Icon, ) } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/Button.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.Serializable @Serializable data class Button( val buttonRenderer: ButtonRenderer, ) { @Serializable data class ButtonRenderer( val text: Runs, val navigationEndpoint: NavigationEndpoint?, val command: NavigationEndpoint?, val icon: Icon?, ) } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/Context.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.Serializable @Serializable data class Context( val client: Client, val thirdParty: ThirdParty? = null, val request: Request = Request(), val user: User = User() ) { @Serializable data class Client( val clientName: String, val clientVersion: String, val osName: String? = null, val osVersion: String? = null, val deviceMake: String? = null, val deviceModel: String? = null, val androidSdkVersion: String? = null, val gl: String, val hl: String, val visitorData: String?, ) @Serializable data class ThirdParty( val embedUrl: String, ) @Serializable data class Request( val internalExperimentFlags: Array = emptyArray(), val useSsl: Boolean = true, ) @Serializable data class User( val lockedSafetyMode: Boolean = false, val onBehalfOfUser: String? = null, ) } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/Continuation.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class Continuation( @JsonNames("nextContinuationData", "nextRadioContinuationData") val nextContinuationData: NextContinuationData?, ) { @Serializable data class NextContinuationData( val continuation: String, ) } fun List.getContinuation() = firstOrNull()?.nextContinuationData?.continuation ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/ContinuationItemRenderer.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.Serializable @Serializable data class ContinuationItemRenderer( val continuationEndpoint: ContinuationEndpoint?, ) { @Serializable data class ContinuationEndpoint( val continuationCommand: ContinuationCommand?, ) { @Serializable data class ContinuationCommand( val token: String?, ) } } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/Endpoint.kt ================================================ package com.metrolist.innertube.models import com.metrolist.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_ALBUM import com.metrolist.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_ARTIST import com.metrolist.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_PLAYLIST import com.metrolist.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_AUDIOBOOK import com.metrolist.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE import kotlinx.serialization.Serializable @Serializable sealed class Endpoint @Serializable data class WatchEndpoint( val videoId: String? = null, val playlistId: String? = null, val playlistSetVideoId: String? = null, val params: String? = null, val index: Int? = null, val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null, ) : Endpoint() { @Serializable data class WatchEndpointMusicSupportedConfigs( val watchEndpointMusicConfig: WatchEndpointMusicConfig, ) { @Serializable data class WatchEndpointMusicConfig( val musicVideoType: String, ) { companion object { const val MUSIC_VIDEO_TYPE_OMV = "MUSIC_VIDEO_TYPE_OMV" const val MUSIC_VIDEO_TYPE_UGC = "MUSIC_VIDEO_TYPE_UGC" const val MUSIC_VIDEO_TYPE_ATV = "MUSIC_VIDEO_TYPE_ATV" } } } } @Serializable data class BrowseEndpoint( val browseId: String, val params: String? = null, val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null, ) : Endpoint() { val isArtistEndpoint: Boolean get() = browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ARTIST val isAlbumEndpoint: Boolean get() = browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ALBUM || browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_AUDIOBOOK val isPlaylistEndpoint: Boolean get() = browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_PLAYLIST val isPodcastEndpoint: Boolean get() = browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE @Serializable data class BrowseEndpointContextSupportedConfigs( val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig, ) { @Serializable data class BrowseEndpointContextMusicConfig( val pageType: String, ) { companion object { const val MUSIC_PAGE_TYPE_ALBUM = "MUSIC_PAGE_TYPE_ALBUM" const val MUSIC_PAGE_TYPE_AUDIOBOOK = "MUSIC_PAGE_TYPE_AUDIOBOOK" const val MUSIC_PAGE_TYPE_PLAYLIST = "MUSIC_PAGE_TYPE_PLAYLIST" const val MUSIC_PAGE_TYPE_ARTIST = "MUSIC_PAGE_TYPE_ARTIST" const val MUSIC_PAGE_TYPE_LIBRARY_ARTIST = "MUSIC_PAGE_TYPE_LIBRARY_ARTIST" const val MUSIC_PAGE_TYPE_USER_CHANNEL = "MUSIC_PAGE_TYPE_USER_CHANNEL" const val MUSIC_PAGE_TYPE_TRACK_LYRICS = "MUSIC_PAGE_TYPE_TRACK_LYRICS" const val MUSIC_PAGE_TYPE_TRACK_RELATED = "MUSIC_PAGE_TYPE_TRACK_RELATED" const val MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE = "MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE" const val MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE = "MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE" } } } } @Serializable data class SearchEndpoint( val params: String?, val query: String, ) : Endpoint() @Serializable data class FeedbackEndpoint( val feedbackToken: String ) : Endpoint() @Serializable data class QueueAddEndpoint( val queueInsertPosition: String, val queueTarget: QueueTarget, ) : Endpoint() { @Serializable data class QueueTarget( val videoId: String? = null, val playlistId: String? = null, ) } @Serializable data class ShareEntityEndpoint( val serializedShareEntity: String, ) : Endpoint() @Serializable data class DefaultServiceEndpoint( var subscribeEndpoint: SubscribeEndpoint?, var feedbackEndpoint: FeedbackEndpoint? ) : Endpoint() { @Serializable data class SubscribeEndpoint( val channelIds: List, val params: String? = null, ) : Endpoint() } @Serializable data class ToggledServiceEndpoint( var feedbackEndpoint: FeedbackEndpoint? ) : Endpoint() ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/GridRenderer.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.Serializable @Serializable data class GridRenderer( val header: Header?, val items: List, val continuations: List?, ) { @Serializable data class Header( val gridHeaderRenderer: GridHeaderRenderer, ) { @Serializable data class GridHeaderRenderer( val title: Runs, ) } @Serializable data class Item( val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?, val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, ) } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/Icon.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.Serializable @Serializable data class Icon( val iconType: String, ) ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/MediaInfo.kt ================================================ package com.metrolist.innertube.models data class MediaInfo( val videoId: String, val title: String? = null, val author: String? = null, val authorId: String? = null, val authorThumbnail: String? = null, val description: String? = null, val uploadDate: String? = null, val subscribers: String? = null, val viewCount: Int? = null, val like: Int? = null, val dislike: Int? = null, ) ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/Menu.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.Serializable @Serializable data class Menu( val menuRenderer: MenuRenderer, ) { @Serializable data class MenuRenderer( val items: List?, val topLevelButtons: List?, ) { @Serializable data class Item( val menuNavigationItemRenderer: MenuNavigationItemRenderer?, val menuServiceItemRenderer: MenuServiceItemRenderer?, val toggleMenuServiceItemRenderer: ToggleMenuServiceRenderer?, ) { @Serializable data class MenuNavigationItemRenderer( val text: Runs, val icon: Icon, val navigationEndpoint: NavigationEndpoint, ) @Serializable data class MenuServiceItemRenderer( val text: Runs, val icon: Icon, val serviceEndpoint: NavigationEndpoint, ) @Serializable data class ToggleMenuServiceRenderer( val defaultIcon: Icon, val defaultServiceEndpoint: DefaultServiceEndpoint, val toggledServiceEndpoint: ToggledServiceEndpoint?, val isSelected: Boolean = false, ) } @Serializable data class TopLevelButton( val buttonRenderer: ButtonRenderer?, ) { @Serializable data class ButtonRenderer( val icon: Icon, val navigationEndpoint: NavigationEndpoint, ) } } } ================================================ FILE: innertube/src/main/kotlin/com/metrolist/innertube/models/MusicCardShelfRenderer.kt ================================================ package com.metrolist.innertube.models import kotlinx.serialization.Serializable @Serializable data class MusicCardShelfRenderer( val title: Runs, val subtitle: Runs, val thumbnail: ThumbnailRenderer, val header: Header?, val contents: List?, val buttons: List