fix: clean up settings merge regressions

This commit is contained in:
zarzet
2026-05-03 01:54:59 +07:00
parent 87dc8eb5ea
commit b329acd710
10 changed files with 283 additions and 413 deletions
+11 -11
View File
@@ -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';
}
+11 -11
View File
@@ -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"
}
}
}
+5 -5
View File
@@ -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": {
+5 -5
View File
@@ -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": {
+12 -12
View File
@@ -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"
}
}
}
+5 -5
View File
@@ -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": {
+38 -6
View File
@@ -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<AppSettings> {
@@ -23,6 +24,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
'track',
'artist',
'album',
'playlist',
};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@@ -51,6 +53,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
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<AppSettings> {
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<AppSettings> {
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<AppSettings> {
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<void> _normalizeSongLinkRegionIfNeeded() async {
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
if (normalized == state.songLinkRegion) return;
@@ -244,7 +273,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setDefaultService(String service) {
state = state.copyWith(defaultService: service);
state = state.copyWith(
defaultService: _sanitizeRetiredBuiltInProviderId(service) ?? '',
);
_saveSettings();
}
@@ -430,10 +461,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
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();
}
+1
View File
@@ -429,6 +429,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
'track' => 'track',
'artist' => 'artist',
'album' => 'album',
'playlist' => 'playlist',
_ => null,
};
+192 -355
View File
@@ -16,19 +16,16 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
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<DownloadSettingsPage> {
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<DownloadSettingsPage> {
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<DownloadSettingsPage> {
);
}
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 = <String, String>{
'US': 'United States',
@@ -358,91 +252,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
return name == null ? effective : '$effective - $name';
}
void _showTidalHighFormatPicker(
BuildContext context,
WidgetRef ref,
String current,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
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<int> 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<Extension> 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<Widget> 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),
),
],
),
],
),
);
}
}
@@ -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),