feat: add default search tab preference

This commit is contained in:
zarzet
2026-04-11 16:42:29 +07:00
parent 3bbe8553ab
commit 3735aaf3bd
22 changed files with 293 additions and 5 deletions
+12
View File
@@ -352,6 +352,18 @@ abstract class AppLocalizations {
/// **'Using extension: {extensionName}'**
String optionsUsingExtension(String extensionName);
/// Title for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Default Search Tab'**
String get optionsDefaultSearchTab;
/// Subtitle for the preferred default search tab setting
///
/// In en, this message translates to:
/// **'Choose which tab opens first for new search results.'**
String get optionsDefaultSearchTabSubtitle;
/// Hint to switch back to built-in providers
///
/// In en, this message translates to:
+7
View File
@@ -129,6 +129,13 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Erweiterung verwenden: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
+7
View File
@@ -127,6 +127,13 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
+7
View File
@@ -127,6 +127,13 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
+7
View File
@@ -128,6 +128,13 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Utilisation de l\'extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
+7
View File
@@ -127,6 +127,13 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
+7
View File
@@ -129,6 +129,13 @@ class AppLocalizationsId extends AppLocalizations {
return 'Menggunakan ekstensi: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Tab Pencarian Default';
@override
String get optionsDefaultSearchTabSubtitle =>
'Pilih tab yang dibuka lebih dulu untuk hasil pencarian baru.';
@override
String get optionsSwitchBack =>
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
+7
View File
@@ -127,6 +127,13 @@ class AppLocalizationsJa extends AppLocalizations {
return '拡張の使用: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
+7
View File
@@ -125,6 +125,13 @@ class AppLocalizationsKo extends AppLocalizations {
return '확장 기능을 사용: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
+7
View File
@@ -127,6 +127,13 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
+7
View File
@@ -127,6 +127,13 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
+7
View File
@@ -129,6 +129,13 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Используется расширение: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Нажмите Deezer или Spotify для возврата с расширения';
+7
View File
@@ -129,6 +129,13 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Kullanılan eklenti: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Dahili kaynaklara dönmek için Deezer veya Spotify\'a tıkla';
+7
View File
@@ -127,6 +127,13 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Using extension: $extensionName';
}
@override
String get optionsDefaultSearchTab => 'Default Search Tab';
@override
String get optionsDefaultSearchTabSubtitle =>
'Choose which tab opens first for new search results.';
@override
String get optionsSwitchBack =>
'Tap Deezer or Spotify to switch back from extension';
+8
View File
@@ -158,6 +158,14 @@
}
}
},
"optionsDefaultSearchTab": "Default Search Tab",
"@optionsDefaultSearchTab": {
"description": "Title for the preferred default search tab setting"
},
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
+8
View File
@@ -150,6 +150,14 @@
}
}
},
"optionsDefaultSearchTab": "Tab Pencarian Default",
"@optionsDefaultSearchTab": {
"description": "Title for the preferred default search tab setting"
},
"optionsDefaultSearchTabSubtitle": "Pilih tab yang dibuka lebih dulu untuk hasil pencarian baru.",
"@optionsDefaultSearchTabSubtitle": {
"description": "Subtitle for the preferred default search tab setting"
},
"optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
"@optionsSwitchBack": {
"description": "Hint to switch back to built-in providers"
+4
View File
@@ -35,6 +35,7 @@ class AppSettings {
final bool useExtensionProviders;
final List<String>? downloadFallbackExtensionIds;
final String? searchProvider;
final String defaultSearchTab;
final String? homeFeedProvider;
final bool separateSingles;
final String singleFilenameFormat;
@@ -111,6 +112,7 @@ class AppSettings {
this.useExtensionProviders = true,
this.downloadFallbackExtensionIds,
this.searchProvider,
this.defaultSearchTab = 'all',
this.homeFeedProvider,
this.separateSingles = false,
this.singleFilenameFormat = '{title} - {artist}',
@@ -176,6 +178,7 @@ class AppSettings {
bool clearDownloadFallbackExtensionIds = false,
String? searchProvider,
bool clearSearchProvider = false,
String? defaultSearchTab,
String? homeFeedProvider,
bool clearHomeFeedProvider = false,
bool? separateSingles,
@@ -242,6 +245,7 @@ class AppSettings {
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
defaultSearchTab: defaultSearchTab ?? this.defaultSearchTab,
homeFeedProvider: clearHomeFeedProvider
? null
: (homeFeedProvider ?? this.homeFeedProvider),
+2
View File
@@ -40,6 +40,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
?.map((e) => e as String)
.toList(),
searchProvider: json['searchProvider'] as String?,
defaultSearchTab: json['defaultSearchTab'] as String? ?? 'all',
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false,
singleFilenameFormat:
@@ -111,6 +112,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'useExtensionProviders': instance.useExtensionProviders,
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
'searchProvider': instance.searchProvider,
'defaultSearchTab': instance.defaultSearchTab,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles,
'singleFilenameFormat': instance.singleFilenameFormat,
+16
View File
@@ -18,6 +18,7 @@ final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
static const Set<String> _searchTabValues = {'all', 'track', 'album'};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
@@ -42,11 +43,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
state = loaded.copyWith(
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
);
await _runMigrations(prefs);
@@ -187,6 +192,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
return 'US';
}
String _normalizeDefaultSearchTab(String value) {
final normalized = value.trim().toLowerCase();
if (_searchTabValues.contains(normalized)) return normalized;
return 'all';
}
Future<void> _normalizeSongLinkRegionIfNeeded() async {
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
if (normalized == state.songLinkRegion) return;
@@ -408,6 +419,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setDefaultSearchTab(String tab) {
state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab));
_saveSettings();
}
void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true);
+5 -2
View File
@@ -741,14 +741,16 @@ class TrackNotifier extends Notifier<TrackState> {
String extensionId,
String query, {
Map<String, dynamic>? options,
String? selectedFilter,
}) async {
final requestId = ++_currentRequestId;
final currentFilter = selectedFilter ?? state.selectedSearchFilter;
state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: state.selectedSearchFilter,
selectedSearchFilter: currentFilter,
);
try {
@@ -788,7 +790,7 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId,
selectedSearchFilter: state.selectedSearchFilter,
selectedSearchFilter: currentFilter,
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -798,6 +800,7 @@ class TrackNotifier extends Notifier<TrackState> {
error: e.toString(),
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter,
);
}
}
+76 -2
View File
@@ -449,6 +449,61 @@ class _HomeTabState extends ConsumerState<HomeTab>
];
}
String? _sanitizeSearchFilterForProvider(
String? filter,
String? currentSearchProvider,
List<Extension> extensions,
) {
if (filter == null || filter.isEmpty) {
return null;
}
if (currentSearchProvider == null ||
currentSearchProvider.isEmpty ||
_builtInSearchProviders.contains(currentSearchProvider)) {
switch (filter) {
case 'track':
case 'artist':
case 'album':
case 'playlist':
return filter;
default:
return null;
}
}
final extension = extensions
.where((e) => e.id == currentSearchProvider && e.enabled)
.firstOrNull;
final filters = extension?.searchBehavior?.filters;
if (filters == null || filters.isEmpty) {
return null;
}
final match = filters
.where((candidate) => candidate.id == filter)
.firstOrNull;
return match?.id;
}
String? _preferredSearchFilter(
String preferredSearchTab,
String? currentSearchProvider,
List<Extension> extensions,
) {
final preferred = switch (preferredSearchTab) {
'track' => 'track',
'album' => 'album',
_ => null,
};
return _sanitizeSearchFilterForProvider(
preferred,
currentSearchProvider,
extensions,
);
}
_SearchResultBuckets _getSearchResultBuckets(List<Track> tracks) {
final cached = _searchBucketsCache;
if (cached != null && identical(tracks, _searchBucketsSourceTracks)) {
@@ -601,7 +656,21 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extState = ref.read(extensionProvider);
final searchProvider = settings.searchProvider;
final selectedFilter =
filterOverride ?? ref.read(trackProvider).selectedSearchFilter;
_sanitizeSearchFilterForProvider(
filterOverride,
searchProvider,
extState.extensions,
) ??
_sanitizeSearchFilterForProvider(
ref.read(trackProvider).selectedSearchFilter,
searchProvider,
extState.extensions,
) ??
_preferredSearchFilter(
settings.defaultSearchTab,
searchProvider,
extState.extensions,
);
final searchKey =
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
@@ -627,7 +696,12 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
await ref
.read(trackProvider.notifier)
.customSearch(searchProvider, query, options: options);
.customSearch(
searchProvider,
query,
options: options,
selectedFilter: selectedFilter,
);
} else if (isBuiltInProvider) {
await ref
.read(trackProvider.notifier)
@@ -70,7 +70,12 @@ class OptionsSettingsPage extends ConsumerWidget {
),
),
SliverToBoxAdapter(
child: SettingsGroup(children: [const _MetadataSourceSelector()]),
child: SettingsGroup(
children: const [
_MetadataSourceSelector(),
_DefaultSearchTabSelector(),
],
),
),
SliverToBoxAdapter(
@@ -826,6 +831,71 @@ class _MetadataSourceSelector extends ConsumerWidget {
}
}
class _DefaultSearchTabSelector extends ConsumerWidget {
const _DefaultSearchTabSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final selectedTab = ref.watch(
settingsProvider.select((s) => s.defaultSearchTab),
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
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: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_SourceChip(
icon: Icons.dashboard_outlined,
label: context.l10n.historyFilterAll,
isSelected: selectedTab == 'all',
onTap: () => ref
.read(settingsProvider.notifier)
.setDefaultSearchTab('all'),
),
_SourceChip(
icon: Icons.music_note,
label: context.l10n.searchSongs,
isSelected: selectedTab == 'track',
onTap: () => ref
.read(settingsProvider.notifier)
.setDefaultSearchTab('track'),
),
_SourceChip(
icon: Icons.album,
label: context.l10n.searchAlbums,
isSelected: selectedTab == 'album',
onTap: () => ref
.read(settingsProvider.notifier)
.setDefaultSearchTab('album'),
),
],
),
],
),
);
}
}
class _SourceChip extends StatelessWidget {
final IconData icon;
final String label;