diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0e83715e..5212ee2c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -651,9 +651,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.'; } @@ -706,9 +706,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Удалено $count $_temp0'; } @@ -1176,9 +1176,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: '$count треков', - one: '1 трек', many: '$count треков', few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @@ -1685,9 +1685,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.'; } @@ -1709,9 +1709,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Удалить $count $_temp0'; } @@ -1942,9 +1942,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return '$_temp0'; } @@ -2098,9 +2098,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: '$count минут', - one: '1 минуту', many: '$count минут', few: '$count минуты', + one: '$count минуту', ); return '$_temp0 назад'; } @@ -2111,9 +2111,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: '$count часов', - one: '1 час', many: '$count часов', few: '$count часа', + one: '$count час', ); return '$_temp0 назад'; } @@ -2598,9 +2598,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: '$count треков', - one: '1 трек', many: '$count треков', few: '$count трека', + one: '$count трек', ); return '$_temp0'; } @@ -2751,9 +2751,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Отправить $count $_temp0'; } @@ -2768,9 +2768,9 @@ class AppLocalizationsRu extends AppLocalizations { count, locale: localeName, other: 'треков', - one: 'трек', many: 'треков', few: 'трека', + one: 'трек', ); return 'Конвертировать $count $_temp0'; } diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index c16f90a3..85114818 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -796,7 +796,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "Lösche {count} {count, plural, one {}=1{Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.", + "dialogDeleteSelectedMessage": "Lösche {count} {count, plural, =1{Track} other{Tracks}} aus dem Verlauf?\n\nDies löscht auch die Dateien aus dem Speicher.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -2133,7 +2133,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "{count} {count, plural, one {}=1{Titel} other{Titel}} aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.", + "downloadedAlbumDeleteMessage": "{count} {count, plural, =1{Titel} other{Titel}} aus diesem Album löschen?\n\nDadurch werden auch die Dateien aus dem Speicher gelöscht.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -2159,7 +2159,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "Lösche {count} {count, plural, one {}=1{Titel}other{Titel}}", + "downloadedAlbumDeleteCount": "Lösche {count} {count, plural, =1{Titel}other{Titel}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { @@ -2674,7 +2674,7 @@ "@timeJustNow": { "description": "Relative time - less than a minute ago" }, - "timeMinutesAgo": "{count, plural, one {}=1{vor 1 Minute} other{vor {count} Minuten}}", + "timeMinutesAgo": "{count, plural, =1{vor 1 Minute} other{vor {count} Minuten}}", "@timeMinutesAgo": { "description": "Relative time - minutes ago", "placeholders": { @@ -2683,7 +2683,7 @@ } } }, - "timeHoursAgo": "{count, plural, one {}=1{vor 1 Stunde} other{vor {count} Stunden}}", + "timeHoursAgo": "{count, plural, =1{vor 1 Stunde} other{vor {count} Stunden}}", "@timeHoursAgo": { "description": "Relative time - hours ago", "placeholders": { @@ -3507,7 +3507,7 @@ "@collectionPlaylistRemoveCover": { "description": "Bottom sheet action to remove custom cover image from a playlist" }, - "selectionShareCount": "Teile {count} {count, plural, one {}=1{Titel}other{Titel}}", + "selectionShareCount": "Teile {count} {count, plural, =1{Titel}other{Titel}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", "placeholders": { @@ -3520,7 +3520,7 @@ "@selectionShareNoFiles": { "description": "Snackbar when no selected files exist on disk" }, - "selectionConvertCount": "Konvertiere {count} {count, plural, one {}=1{Titel}other{Titel}}", + "selectionConvertCount": "Konvertiere {count} {count, plural, =1{Titel}other{Titel}}", "@selectionConvertCount": { "description": "Convert button text with count in selection mode", "placeholders": { @@ -3537,7 +3537,7 @@ "@selectionBatchConvertConfirmTitle": { "description": "Confirmation dialog title for batch conversion" }, - "selectionBatchConvertConfirmMessage": "Konvertiere {count} {format} {count, plural, one {}=1{Titel} other{Titel}} zu {bitrate}?\n\nOriginaldateien werden nach der Konvertierung gelöscht.", + "selectionBatchConvertConfirmMessage": "Konvertiere {count} {format} {count, plural, =1{Titel} other{Titel}} zu {bitrate}?\n\nOriginaldateien werden nach der Konvertierung gelöscht.", "@selectionBatchConvertConfirmMessage": { "description": "Confirmation dialog message for batch conversion", "placeholders": { @@ -3552,7 +3552,7 @@ } } }, - "selectionBatchConvertConfirmMessageLossless": "Konvertiere {count} {count, plural, one {}=1{Titel} other{Titel}} in {format}? (kein Qualitätsverlust)\n\nOriginaldateien werden nach der Konvertierung gelöscht.", + "selectionBatchConvertConfirmMessageLossless": "Konvertiere {count} {count, plural, =1{Titel} other{Titel}} in {format}? (kein Qualitätsverlust)\n\nOriginaldateien werden nach der Konvertierung gelöscht.", "@selectionBatchConvertConfirmMessageLossless": { "description": "Confirmation dialog message for lossless batch conversion", "placeholders": { @@ -3962,7 +3962,7 @@ "@cacheRefresh": { "description": "Tooltip for refresh button on cache management page" }, - "dialogDownloadPlaylistsMessage": "Lade {trackCount} {trackCount, plural, one {}=1{Titel} other{Titel}} von {playlistCount} {playlistCount, plural, one {}=1{Playlist} other{Playlists}}?", + "dialogDownloadPlaylistsMessage": "Lade {trackCount} {trackCount, plural, =1{Titel} other{Titel}} von {playlistCount} {playlistCount, plural, =1{Playlist} other{Playlists}}?", "@dialogDownloadPlaylistsMessage": { "description": "Dialog message for bulk playlist download confirmation", "placeholders": { @@ -4590,4 +4590,4 @@ "@downloadFallbackExtensionsSubtitle": { "description": "Subtitle for fallback extensions item" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 3d1432d2..32fc7fb8 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -796,7 +796,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", + "dialogDeleteSelectedMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -875,7 +875,7 @@ "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "Eliminado {count} {count, plural, one {}=1{pista} other{pistas}}", + "snackbarDeletedTracks": "Eliminado {count} {count, plural, =1{pista} other{pistas}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -1490,7 +1490,7 @@ } } }, - "tracksCount": "{count, plural, one {}=1{1 pista} other{{count} pistas}}", + "tracksCount": "{count, plural, =1{1 pista} other{{count} pistas}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -2133,7 +2133,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, one {}=1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", + "downloadedAlbumDeleteMessage": "¿Eliminar {count} {count, plural, =1{pista} other{pistas}} del historial?\n\nEsto también eliminará los archivos del almacenamiento.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -2159,7 +2159,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, one {}=1{pista} other{pistas}}", + "downloadedAlbumDeleteCount": "¡Eliminar {count} {count, plural, =1{pista} other{pistas}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index a03a78e4..5443f666 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -796,7 +796,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}} do histórico?\n\nIsso também apagará os arquivos do armazenamento.", + "dialogDeleteSelectedMessage": "Apagar {count} {count, plural, =1{faixa} other{faixas}} do histórico?\n\nIsso também apagará os arquivos do armazenamento.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -875,7 +875,7 @@ "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "{count} {count, plural, one {}=1{faixa apagada} other{faixas apagadas}}", + "snackbarDeletedTracks": "{count} {count, plural, =1{faixa apagada} other{faixas apagadas}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -1490,7 +1490,7 @@ } } }, - "tracksCount": "{count, plural, one {}=1{1 faixa} other{{count} faixas}}", + "tracksCount": "{count, plural, =1{1 faixa} other{{count} faixas}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -2133,7 +2133,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "Excluir {count} {count, plural, one {}=1{faixa} other{faixas}} deste álbum?\n\nIsso também excluirá os arquivos do armazenamento.", + "downloadedAlbumDeleteMessage": "Excluir {count} {count, plural, =1{faixa} other{faixas}} deste álbum?\n\nIsso também excluirá os arquivos do armazenamento.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -2159,7 +2159,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "Apagar {count} {count, plural, one {}=1{faixa} other{faixas}}", + "downloadedAlbumDeleteCount": "Apagar {count} {count, plural, =1{faixa} other{faixas}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index a3e2658c..d3b4de6a 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -796,7 +796,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", + "dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -875,7 +875,7 @@ "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -1490,7 +1490,7 @@ } } }, - "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", + "tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -2133,7 +2133,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", + "downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -2159,7 +2159,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { @@ -2482,7 +2482,7 @@ "@libraryAboutDescription": { "description": "Description of local library feature" }, - "libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "libraryTracksUnit": "{count, plural, one {трек} few {трека} many {треков} other{треков}}", "@libraryTracksUnit": { "description": "Unit label for tracks count (without the number itself)", "placeholders": { @@ -2674,7 +2674,7 @@ "@timeJustNow": { "description": "Relative time - less than a minute ago" }, - "timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} =1 {1 минуту} other {{count} минут}} назад", + "timeMinutesAgo": "{count, plural, one {{count} минуту} few {{count} минуты} many {{count} минут} other {{count} минут}} назад", "@timeMinutesAgo": { "description": "Relative time - minutes ago", "placeholders": { @@ -2683,7 +2683,7 @@ } } }, - "timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} =1 {1 час} other {{count} часов}} назад", + "timeHoursAgo": "{count, plural, one {{count} час} few {{count} часа} many {{count} часов} other {{count} часов}} назад", "@timeHoursAgo": { "description": "Relative time - hours ago", "placeholders": { @@ -3342,7 +3342,7 @@ "@collectionNoPlaylistsSubtitle": { "description": "Empty state subtitle when user has no playlists" }, - "collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}", + "collectionPlaylistTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}", "@collectionPlaylistTracks": { "description": "Track count label for custom playlists", "placeholders": { @@ -3507,7 +3507,7 @@ "@collectionPlaylistRemoveCover": { "description": "Bottom sheet action to remove custom cover image from a playlist" }, - "selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "selectionShareCount": "Отправить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@selectionShareCount": { "description": "Share button text with count in selection mode", "placeholders": { @@ -3520,7 +3520,7 @@ "@selectionShareNoFiles": { "description": "Snackbar when no selected files exist on disk" }, - "selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}", + "selectionConvertCount": "Конвертировать {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}", "@selectionConvertCount": { "description": "Convert button text with count in selection mode", "placeholders": { @@ -4590,4 +4590,4 @@ "@downloadFallbackExtensionsSubtitle": { "description": "Subtitle for fallback extensions item" } -} \ No newline at end of file +} diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index bd001a74..a4eed63e 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -736,7 +736,7 @@ "@dialogDeleteSelectedTitle": { "description": "Dialog title - delete selected items" }, - "dialogDeleteSelectedMessage": "{count} {count, plural, one {}=1{şarkıyı} other{şarkıyı}} geçmişten silmeye emin misiniz?\n\nBu işlem seçilenleri cihazınızdan da silecektir.", + "dialogDeleteSelectedMessage": "{count} {count, plural, =1{şarkıyı} other{şarkıyı}} geçmişten silmeye emin misiniz?\n\nBu işlem seçilenleri cihazınızdan da silecektir.", "@dialogDeleteSelectedMessage": { "description": "Dialog message - delete selected tracks", "placeholders": { @@ -815,7 +815,7 @@ "@snackbarCredentialsCleared": { "description": "Snackbar - Spotify credentials removed" }, - "snackbarDeletedTracks": "{count} {count, plural, one {}=1{şarkı} other{şarkı}} silindi", + "snackbarDeletedTracks": "{count} {count, plural, =1{şarkı} other{şarkı}} silindi", "@snackbarDeletedTracks": { "description": "Snackbar - tracks deleted", "placeholders": { @@ -1418,7 +1418,7 @@ } } }, - "tracksCount": "{count, plural, one {}=1{1 parça} other{{count} parça}}", + "tracksCount": "{count, plural, =1{1 parça} other{{count} parça}}", "@tracksCount": { "description": "Track count display", "placeholders": { @@ -2045,7 +2045,7 @@ "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" }, - "downloadedAlbumDeleteMessage": "Bu albümden {count} {count, plural, one {}=1{parça} other{parça}} parça silinsin mi?\n\nBu işlem dosyaları depolama alanından da kalıcı olarak silecektir.", + "downloadedAlbumDeleteMessage": "Bu albümden {count} {count, plural, =1{parça} other{parça}} parça silinsin mi?\n\nBu işlem dosyaları depolama alanından da kalıcı olarak silecektir.", "@downloadedAlbumDeleteMessage": { "description": "Delete confirmation with count", "placeholders": { @@ -2071,7 +2071,7 @@ "@downloadedAlbumTapToSelect": { "description": "Selection hint" }, - "downloadedAlbumDeleteCount": "{count} {count, plural, one {}=1{parçayı} other{parçayı}} sil", + "downloadedAlbumDeleteCount": "{count} {count, plural, =1{parçayı} other{parçayı}} sil", "@downloadedAlbumDeleteCount": { "description": "Delete button text with count", "placeholders": { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 68302148..e6ca78d0 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -14,6 +14,7 @@ const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; const _currentMigrationVersion = 11; const _spotifyClientSecretKey = 'spotify_client_secret'; +const _retiredBuiltInProviderIds = {'deezer', 'qobuz', 'tidal', 'youtube'}; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { @@ -23,6 +24,7 @@ class SettingsNotifier extends Notifier { 'track', 'artist', 'album', + 'playlist', }; final Future _prefs = SharedPreferences.getInstance(); @@ -51,6 +53,12 @@ class SettingsNotifier extends Notifier { final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab( loaded.defaultSearchTab, ); + final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId( + loaded.defaultService, + ); + final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId( + loaded.searchProvider, + ); state = loaded.copyWith( useExtensionProviders: true, downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds, @@ -58,6 +66,10 @@ class SettingsNotifier extends Notifier { loaded.downloadFallbackExtensionIds != null && sanitizedDownloadFallbackExtensionIds == null, defaultSearchTab: sanitizedDefaultSearchTab, + defaultService: sanitizedDefaultService ?? '', + searchProvider: sanitizedSearchProvider, + clearSearchProvider: + loaded.searchProvider != null && sanitizedSearchProvider == null, ); await _runMigrations(prefs); @@ -145,9 +157,20 @@ class SettingsNotifier extends Notifier { state = state.copyWith(lastSeenVersion: AppInfo.version); // Migration 7/11: retired built-in services no longer fall back to a // preinstalled provider. - if (state.defaultService == 'youtube' || - state.defaultService == 'deezer') { - state = state.copyWith(defaultService: ''); + final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId( + state.defaultService, + ); + final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId( + state.searchProvider, + ); + if (sanitizedDefaultService != state.defaultService || + sanitizedSearchProvider != state.searchProvider) { + state = state.copyWith( + defaultService: sanitizedDefaultService ?? '', + searchProvider: sanitizedSearchProvider, + clearSearchProvider: + state.searchProvider != null && sanitizedSearchProvider == null, + ); } if (!state.useExtensionProviders) { state = state.copyWith(useExtensionProviders: true); @@ -211,6 +234,12 @@ class SettingsNotifier extends Notifier { return 'all'; } + String? _sanitizeRetiredBuiltInProviderId(String? providerId) { + final normalized = providerId?.trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) return providerId; + return _retiredBuiltInProviderIds.contains(normalized) ? null : providerId; + } + Future _normalizeSongLinkRegionIfNeeded() async { final normalized = _normalizeSongLinkRegion(state.songLinkRegion); if (normalized == state.songLinkRegion) return; @@ -244,7 +273,9 @@ class SettingsNotifier extends Notifier { } void setDefaultService(String service) { - state = state.copyWith(defaultService: service); + state = state.copyWith( + defaultService: _sanitizeRetiredBuiltInProviderId(service) ?? '', + ); _saveSettings(); } @@ -430,10 +461,11 @@ class SettingsNotifier extends Notifier { } void setSearchProvider(String? provider) { - if (provider == null || provider.isEmpty) { + final sanitized = _sanitizeRetiredBuiltInProviderId(provider); + if (sanitized == null || sanitized.isEmpty) { state = state.copyWith(clearSearchProvider: true); } else { - state = state.copyWith(searchProvider: provider); + state = state.copyWith(searchProvider: sanitized); } _saveSettings(); } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index aea8e83b..18c16d55 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -429,6 +429,7 @@ class _HomeTabState extends ConsumerState 'track' => 'track', 'artist' => 'artist', 'album' => 'album', + 'playlist' => 'playlist', _ => null, }; diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 251b4533..f6ea42a6 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -16,19 +16,16 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz']; - @override Widget build(BuildContext context) { final settings = ref.watch(settingsProvider); final extensionState = ref.watch(extensionProvider); - final hasExtensions = extensionState.extensions.isNotEmpty; + final hasDownloadExtensions = extensionState.extensions.any( + (extension) => extension.enabled && extension.hasDownloadProvider, + ); final colorScheme = Theme.of(context).colorScheme; final topPadding = normalizedHeaderTopPadding(context); - final isBuiltInService = _builtInServices.contains(settings.defaultService); - final isTidalService = settings.defaultService == 'tidal'; - return PopScope( canPop: true, child: Scaffold( @@ -103,94 +100,16 @@ class _DownloadSettingsPageState extends ConsumerState { SettingsSwitchItem( icon: Icons.tune, title: context.l10n.downloadAskBeforeDownload, - subtitle: isBuiltInService + subtitle: hasDownloadExtensions ? context.l10n.downloadAskQualitySubtitle - : context.l10n.downloadSelectServiceToEnable, + : context.l10n.extensionsNoDownloadProvider, value: settings.askQualityBeforeDownload, - enabled: isBuiltInService, + enabled: hasDownloadExtensions, onChanged: (value) => ref .read(settingsProvider.notifier) .setAskQualityBeforeDownload(value), + showDivider: false, ), - if (!settings.askQualityBeforeDownload && - isBuiltInService) ...[ - _QualityOption( - title: context.l10n.qualityFlacLossless, - subtitle: context.l10n.qualityFlacLosslessSubtitle, - isSelected: settings.audioQuality == 'LOSSLESS', - onTap: () => ref - .read(settingsProvider.notifier) - .setAudioQuality('LOSSLESS'), - ), - _QualityOption( - title: context.l10n.qualityHiResFlac, - subtitle: context.l10n.qualityHiResFlacSubtitle, - isSelected: settings.audioQuality == 'HI_RES', - onTap: () => ref - .read(settingsProvider.notifier) - .setAudioQuality('HI_RES'), - ), - _QualityOption( - title: context.l10n.qualityHiResFlacMax, - subtitle: context.l10n.qualityHiResFlacMaxSubtitle, - isSelected: settings.audioQuality == 'HI_RES_LOSSLESS', - onTap: () => ref - .read(settingsProvider.notifier) - .setAudioQuality('HI_RES_LOSSLESS'), - showDivider: isTidalService, - ), - if (isTidalService) - _QualityOption( - title: context.l10n.downloadLossy320, - subtitle: _getTidalHighFormatLabel( - context, - settings.tidalHighFormat, - ), - isSelected: settings.audioQuality == 'HIGH', - onTap: () => ref - .read(settingsProvider.notifier) - .setAudioQuality('HIGH'), - showDivider: false, - ), - if (isTidalService && settings.audioQuality == 'HIGH') - SettingsItem( - icon: Icons.tune, - title: context.l10n.downloadLossyFormat, - subtitle: _getTidalHighFormatLabel( - context, - settings.tidalHighFormat, - ), - onTap: () => _showTidalHighFormatPicker( - context, - ref, - settings.tidalHighFormat, - ), - showDivider: false, - ), - ], - if (!isBuiltInService) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( - children: [ - Icon( - Icons.info_outline, - size: 16, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - context.l10n.downloadSelectTidalQobuz, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - ), ], ), ), @@ -257,18 +176,6 @@ class _DownloadSettingsPageState extends ConsumerState { onChanged: (v) => ref.read(settingsProvider.notifier).setAutoFallback(v), ), - if (hasExtensions) - SettingsSwitchItem( - icon: Icons.extension, - title: context.l10n.optionsUseExtensionProviders, - subtitle: settings.useExtensionProviders - ? context.l10n.optionsUseExtensionProvidersOn - : context.l10n.optionsUseExtensionProvidersOff, - value: settings.useExtensionProviders, - onChanged: (v) => ref - .read(settingsProvider.notifier) - .setUseExtensionProviders(v), - ), SettingsItem( icon: Icons.extension_outlined, title: context.l10n.downloadFallbackExtensions, @@ -323,19 +230,6 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - String _getTidalHighFormatLabel(BuildContext context, String format) { - switch (format) { - case 'mp3_320': - return context.l10n.downloadLossyMp3; - case 'opus_256': - return context.l10n.downloadLossyOpus256; - case 'opus_128': - return context.l10n.downloadLossyOpus128; - default: - return context.l10n.downloadLossyMp3; - } - } - String _getSongLinkRegionLabel(String code) { const names = { 'US': 'United States', @@ -358,91 +252,6 @@ class _DownloadSettingsPageState extends ConsumerState { return name == null ? effective : '$effective - $name'; } - void _showTidalHighFormatPicker( - BuildContext context, - WidgetRef ref, - String current, - ) { - final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), - child: Text( - context.l10n.downloadLossy320Format, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), - child: Text( - context.l10n.downloadLossy320FormatDesc, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ListTile( - leading: const Icon(Icons.audiotrack), - title: Text(context.l10n.downloadLossyMp3), - subtitle: Text(context.l10n.downloadLossyMp3Subtitle), - trailing: current == 'mp3_320' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('mp3_320'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: Text(context.l10n.downloadLossyOpus256), - subtitle: Text(context.l10n.downloadLossyOpus256Subtitle), - trailing: current == 'opus_256' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('opus_256'); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.graphic_eq), - title: Text(context.l10n.downloadLossyOpus128), - subtitle: Text(context.l10n.downloadLossyOpus128Subtitle), - trailing: current == 'opus_128' - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - ref - .read(settingsProvider.notifier) - .setTidalHighFormat('opus_128'); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], - ), - ), - ); - } - void _showNetworkModePicker( BuildContext context, WidgetRef ref, @@ -795,63 +604,61 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) .toList(); - final isExtensionService = !builtInServiceIds.contains(currentService); - final isCurrentExtensionEnabled = isExtensionService - ? extensionProviders.any((e) => e.id == currentService) - : true; - - final effectiveService = isCurrentExtensionEnabled ? currentService : ''; + final effectiveService = + extensionProviders.any((extension) => extension.id == currentService) + ? currentService + : ''; return Padding( padding: const EdgeInsets.all(12), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: _ServiceChip( - icon: Icons.music_note, - label: 'Tidal', - isSelected: effectiveService == 'tidal', - onTap: () => onChanged('tidal'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _ServiceChip( - icon: Icons.album, - label: 'Qobuz', - isSelected: effectiveService == 'qobuz', - onTap: () => onChanged('qobuz'), - ), - ), - ], - ), - if (extensionProviders.isNotEmpty) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, + child: extensionProviders.isEmpty + ? Row( children: [ - for (final extension in extensionProviders) - _ServiceChip( - icon: Icons.extension, - label: extension.displayName, - isSelected: effectiveService == extension.id, - onTap: () => onChanged(extension.id), + Icon( + Icons.info_outline, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + context.l10n.extensionsNoDownloadProvider, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), + ), ], + ) + : LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final chipWidth = (constraints.maxWidth - spacing) / 2; + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final extension in extensionProviders) + SizedBox( + width: chipWidth, + child: _ServiceChip( + icon: Icons.extension, + label: extension.displayName, + isSelected: effectiveService == extension.id, + onTap: () => onChanged(extension.id), + ), + ), + ], + ); + }, ), - ], - ], - ), ); } } @@ -914,67 +721,6 @@ class _ServiceChip extends StatelessWidget { } } -class _QualityOption extends StatelessWidget { - final String title; - final String subtitle; - final bool isSelected; - final VoidCallback onTap; - final bool showDivider; - const _QualityOption({ - required this.title, - required this.subtitle, - required this.isSelected, - required this.onTap, - this.showDivider = true, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.bodyLarge), - const SizedBox(height: 2), - Text( - subtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - isSelected - ? Icon(Icons.check_circle, color: colorScheme.primary) - : Icon(Icons.circle_outlined, color: colorScheme.outline), - ], - ), - ), - ), - if (showDivider) - Divider( - height: 1, - thickness: 1, - indent: 20, - endIndent: 20, - color: colorScheme.outlineVariant.withValues(alpha: 0.3), - ), - ], - ); - } -} - class _ConcurrentDownloadsItem extends StatelessWidget { final int currentValue; final ValueChanged onChanged; @@ -1112,8 +858,6 @@ class _ConcurrentChip extends StatelessWidget { class _MetadataSourceSelector extends ConsumerWidget { const _MetadataSourceSelector(); - static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'}; - Extension? _defaultSearchExtension(List extensions) { return extensions .where( @@ -1135,34 +879,29 @@ class _MetadataSourceSelector extends ConsumerWidget { final extState = ref.watch(extensionProvider); final rawSearchProvider = settings.searchProvider?.trim() ?? ''; - final isValidBuiltIn = _builtInProviders.containsKey(rawSearchProvider); final primarySearchExtension = _defaultSearchExtension(extState.extensions); final defaultProviderTarget = - primarySearchExtension?.displayName ?? 'Tidal'; + primarySearchExtension?.displayName ?? + context.l10n.extensionsNoCustomSearch; final defaultProviderLabel = '${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)'; final searchProvider = - isValidBuiltIn || - extState.extensions.any( - (e) => - e.enabled && e.hasCustomSearch && e.id == rawSearchProvider, - ) + extState.extensions.any( + (e) => e.enabled && e.hasCustomSearch && e.id == rawSearchProvider, + ) ? rawSearchProvider : ''; - final isBuiltIn = _builtInProviders.containsKey(searchProvider); Extension? activeExtension; - if (searchProvider.isNotEmpty && !isBuiltIn) { + if (searchProvider.isNotEmpty) { activeExtension = extState.extensions .where((e) => e.id == searchProvider && e.enabled) .firstOrNull; } - final hasNonDefaultProvider = isBuiltIn || activeExtension != null; + final hasNonDefaultProvider = activeExtension != null; String subtitle; - if (isBuiltIn) { - subtitle = 'Using ${_builtInProviders[searchProvider]}'; - } else if (activeExtension != null) { + if (activeExtension != null) { subtitle = context.l10n.optionsUsingExtension( activeExtension.displayName, ); @@ -1191,28 +930,20 @@ class _MetadataSourceSelector extends ConsumerWidget { ), ), const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, + _SettingsChoiceGrid( children: [ - _SearchProviderChip( + _SettingsChoiceChip( + icon: Icons.auto_awesome, label: defaultProviderLabel, isSelected: searchProvider.isEmpty, onTap: () => ref.read(settingsProvider.notifier).setSearchProvider(''), ), - for (final entry in _builtInProviders.entries) - _SearchProviderChip( - label: entry.value, - isSelected: searchProvider == entry.key, - onTap: () => ref - .read(settingsProvider.notifier) - .setSearchProvider(entry.key), - ), for (final ext in extState.extensions.where( (e) => e.enabled && e.hasCustomSearch, )) - _SearchProviderChip( + _SettingsChoiceChip( + icon: Icons.extension, label: ext.displayName, isSelected: searchProvider == ext.id, onTap: () => ref @@ -1227,11 +958,36 @@ class _MetadataSourceSelector extends ConsumerWidget { } } -class _SearchProviderChip extends StatelessWidget { +class _SettingsChoiceGrid extends StatelessWidget { + final List children; + const _SettingsChoiceGrid({required this.children}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final chipWidth = (constraints.maxWidth - spacing) / 2; + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final child in children) + SizedBox(width: chipWidth, child: child), + ], + ); + }, + ); + } +} + +class _SettingsChoiceChip extends StatelessWidget { + final IconData icon; final String label; final bool isSelected; final VoidCallback onTap; - const _SearchProviderChip({ + const _SettingsChoiceChip({ + required this.icon, required this.label, required this.isSelected, required this.onTap, @@ -1240,16 +996,51 @@ class _SearchProviderChip extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return FilterChip( - label: Text(label), - selected: isSelected, - onSelected: (_) => onTap(), - selectedColor: colorScheme.primaryContainer, - checkmarkColor: colorScheme.onPrimaryContainer, - labelStyle: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurface, + final isDark = Theme.of(context).brightness == Brightness.dark; + final unselectedColor = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + colorScheme.surface, + ) + : colorScheme.surfaceContainerHigh; + return Material( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 18, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), ), ); } @@ -1258,21 +1049,67 @@ class _SearchProviderChip extends StatelessWidget { class _DefaultSearchTabSelector extends ConsumerWidget { const _DefaultSearchTabSelector(); + String _labelForTab(BuildContext context, String tab) { + return switch (tab) { + 'track' => context.l10n.searchTracks, + 'artist' => context.l10n.searchArtists, + 'album' => context.l10n.searchAlbums, + 'playlist' => context.l10n.searchPlaylists, + _ => context.l10n.historyFilterAll, + }; + } + @override Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(settingsProvider); - return SettingsItem( - icon: Icons.tab_outlined, - title: context.l10n.optionsDefaultSearchTab, - subtitle: settings.defaultSearchTab == 'albums' - ? context.l10n.optionsDefaultSearchTabAlbums - : context.l10n.optionsDefaultSearchTabTracks, - onTap: () { - final current = settings.defaultSearchTab; - ref - .read(settingsProvider.notifier) - .setDefaultSearchTab(current == 'albums' ? 'tracks' : 'albums'); - }, + final current = settings.defaultSearchTab; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.optionsDefaultSearchTab, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + context.l10n.optionsDefaultSearchTabSubtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + _SettingsChoiceGrid( + children: [ + for (final tab in const [ + 'all', + 'track', + 'artist', + 'album', + 'playlist', + ]) + _SettingsChoiceChip( + icon: switch (tab) { + 'track' => Icons.music_note, + 'artist' => Icons.person, + 'album' => Icons.album, + 'playlist' => Icons.queue_music, + _ => Icons.grid_view, + }, + label: _labelForTab(context, tab), + isSelected: current == tab, + onTap: () => ref + .read(settingsProvider.notifier) + .setDefaultSearchTab(tab), + ), + ], + ), + ], + ), ); } } diff --git a/lib/screens/settings/metadata_settings_page.dart b/lib/screens/settings/metadata_settings_page.dart index 83f1afb1..d3485517 100644 --- a/lib/screens/settings/metadata_settings_page.dart +++ b/lib/screens/settings/metadata_settings_page.dart @@ -70,10 +70,10 @@ class MetadataSettingsPage extends ConsumerWidget { children: [ SettingsSwitchItem( icon: Icons.sell_outlined, - title: 'Embed Metadata', + title: context.l10n.optionsEmbedMetadata, subtitle: settings.embedMetadata - ? 'Write metadata, cover art, and lyrics to files' - : 'Disabled (advanced): skip all metadata embedding', + ? context.l10n.optionsEmbedMetadataSubtitleOn + : context.l10n.optionsEmbedMetadataSubtitleOff, value: settings.embedMetadata, onChanged: (v) => ref.read(settingsProvider.notifier).setEmbedMetadata(v),