From a15313e573ef221691236501cfee622fadf91375 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 13 Apr 2026 00:44:35 +0700 Subject: [PATCH] feat: add artist search filter and normalize search filter handling --- go_backend/extension_providers.go | 3 - lib/providers/extension_provider.dart | 22 ++- lib/providers/settings_provider.dart | 2 +- lib/providers/track_provider.dart | 9 +- lib/screens/home_tab.dart | 136 +++++++++++++++--- .../settings/options_settings_page.dart | 125 +++++++++------- 6 files changed, 215 insertions(+), 82 deletions(-) diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 60c609d7..7741ae17 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -805,9 +805,6 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string { } normalizedBuiltIn := strings.ToLower(providerID) - if normalizedBuiltIn == "deezer" { - continue - } if isBuiltInDownloadProvider(normalizedBuiltIn) { providerID = normalizedBuiltIn } diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index ba77fdf8..a86b8ca5 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -603,6 +603,7 @@ class ExtensionNotifier extends Notifier { final list = await PlatformBridge.getInstalledExtensions(); final extensions = list.map((e) => Extension.fromJson(e)).toList(); state = state.copyWith(extensions: extensions); + await _reconcileDownloadProviderPriority(); _log.d('Loaded ${extensions.length} extensions'); for (final ext in extensions) { @@ -698,6 +699,7 @@ class ExtensionNotifier extends Notifier { }).toList(); state = state.copyWith(extensions: extensions); + await _reconcileDownloadProviderPriority(); if (!enabled && ext != null) { final settings = ref.read(settingsProvider); @@ -722,6 +724,23 @@ class ExtensionNotifier extends Notifier { } } + Future _reconcileDownloadProviderPriority() async { + if (state.providerPriority.isEmpty) { + return; + } + + final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority); + if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) { + return; + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_providerPriorityKey, jsonEncode(sanitized)); + await PlatformBridge.setProviderPriority(sanitized); + state = state.copyWith(providerPriority: sanitized); + _log.d('Reconciled provider priority after extension update: $sanitized'); + } + Future ensureSpotifyWebExtensionReady({ bool setAsSearchProvider = true, }) async { @@ -849,6 +868,7 @@ class ExtensionNotifier extends Notifier { List _sanitizeDownloadProviderPriority(List input) { final allowed = getAllDownloadProviders().toSet(); + final preferredOrder = getAllDownloadProviders(); final result = []; for (final provider in input) { @@ -857,7 +877,7 @@ class ExtensionNotifier extends Notifier { } } - for (final provider in const ['tidal', 'qobuz']) { + for (final provider in preferredOrder) { if (!result.contains(provider)) { result.add(provider); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 01bc97e4..4e2ea6ca 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -18,7 +18,7 @@ final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); - static const Set _searchTabValues = {'all', 'track', 'album'}; + static const Set _searchTabValues = {'all', 'track', 'artist', 'album'}; final Future _prefs = SharedPreferences.getInstance(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index a02d592d..ac5b507a 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -584,6 +584,7 @@ class TrackNotifier extends Notifier { }) async { final requestId = ++_currentRequestId; final currentFilter = filterOverride ?? state.selectedSearchFilter; + final requestFilter = currentFilter == 'all' ? null : currentFilter; final settings = ref.read(settingsProvider); final extensionState = ref.read(extensionProvider); @@ -647,7 +648,7 @@ class TrackNotifier extends Notifier { extensionState.extensions.any( (ext) => ext.enabled && ext.id == resolvedProvider, )) { - final resolvedFilter = currentFilter ?? 'track'; + final resolvedFilter = requestFilter ?? 'track'; Map? options; options = {'filter': resolvedFilter}; await customSearch( @@ -692,7 +693,7 @@ class TrackNotifier extends Notifier { final effectiveProvider = effectiveBuiltInProvider; _log.i( - 'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter', + 'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter', ); Map results; @@ -705,7 +706,7 @@ class TrackNotifier extends Notifier { query, trackLimit: 20, artistLimit: 2, - filter: currentFilter, + filter: requestFilter, ); break; case 'qobuz': @@ -714,7 +715,7 @@ class TrackNotifier extends Notifier { query, trackLimit: 20, artistLimit: 2, - filter: currentFilter, + filter: requestFilter, ); break; default: diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 912b8ebd..7fcbfcba 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -492,15 +492,17 @@ class _HomeTabState extends ConsumerState return null; } + final canonicalFilter = _canonicalSearchFilterId(filter); + if (currentSearchProvider == null || currentSearchProvider.isEmpty || _builtInSearchProviders.contains(currentSearchProvider)) { - switch (filter) { + switch (canonicalFilter) { case 'track': case 'artist': case 'album': case 'playlist': - return filter; + return canonicalFilter; default: return null; } @@ -515,11 +517,46 @@ class _HomeTabState extends ConsumerState } final match = filters - .where((candidate) => candidate.id == filter) + .where( + (candidate) => + _canonicalSearchFilterId(candidate.id) == canonicalFilter || + (candidate.label != null && + _canonicalSearchFilterId(candidate.label!) == + canonicalFilter) || + (candidate.icon != null && + _canonicalSearchFilterId(candidate.icon!) == + canonicalFilter), + ) .firstOrNull; return match?.id; } + String _canonicalSearchFilterId(String value) { + final normalized = value.trim().toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]+'), + '', + ); + switch (normalized) { + case 'track': + case 'tracks': + case 'song': + case 'songs': + case 'music': + return 'track'; + case 'artist': + case 'artists': + return 'artist'; + case 'album': + case 'albums': + return 'album'; + case 'playlist': + case 'playlists': + return 'playlist'; + default: + return normalized; + } + } + String? _preferredSearchFilter( String preferredSearchTab, String? currentSearchProvider, @@ -527,6 +564,7 @@ class _HomeTabState extends ConsumerState ) { final preferred = switch (preferredSearchTab) { 'track' => 'track', + 'artist' => 'artist', 'album' => 'album', _ => null, }; @@ -538,6 +576,31 @@ class _HomeTabState extends ConsumerState ); } + String _displaySearchFilterSelection( + String? selectedSearchFilter, + String preferredSearchTab, + String? currentSearchProvider, + List extensions, + ) { + if (selectedSearchFilter == 'all') { + return 'all'; + } + if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) { + return _sanitizeSearchFilterForProvider( + selectedSearchFilter, + currentSearchProvider, + extensions, + ) ?? + 'all'; + } + return _preferredSearchFilter( + preferredSearchTab, + currentSearchProvider, + extensions, + ) ?? + 'all'; + } + _SearchResultBuckets _getSearchResultBuckets(List tracks) { final cached = _searchBucketsCache; if (cached != null && identical(tracks, _searchBucketsSourceTracks)) { @@ -695,22 +758,28 @@ class _HomeTabState extends ConsumerState settings.searchProvider, extState.extensions, ); - final selectedFilter = - _sanitizeSearchFilterForProvider( - filterOverride, + final storedFilter = ref.read(trackProvider).selectedSearchFilter; + final selectedFilter = switch (filterOverride) { + 'all' => null, + final explicit? => _sanitizeSearchFilterForProvider( + explicit, + searchProvider, + extState.extensions, + ), + null => switch (storedFilter) { + 'all' => null, + final stored? => _sanitizeSearchFilterForProvider( + stored, searchProvider, extState.extensions, - ) ?? - _sanitizeSearchFilterForProvider( - ref.read(trackProvider).selectedSearchFilter, - searchProvider, - extState.extensions, - ) ?? - _preferredSearchFilter( + ), + null => _preferredSearchFilter( settings.defaultSearchTab, searchProvider, extState.extensions, - ); + ), + }, + }; final searchKey = '${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}'; @@ -1176,6 +1245,9 @@ class _HomeTabState extends ConsumerState final hasSearchedBefore = ref.watch( settingsProvider.select((s) => s.hasSearchedBefore), ); + final defaultSearchTab = ref.watch( + settingsProvider.select((s) => s.defaultSearchTab), + ); final hasExploreContent = ref.watch( exploreProvider.select((s) => s.sections.isNotEmpty), @@ -1217,6 +1289,29 @@ class _HomeTabState extends ConsumerState (hasHomeFeedExtension || hasExploreContent) && hasExploreContent; + ref.listen( + settingsProvider.select((s) => s.defaultSearchTab), + (previous, next) { + if (previous == next) return; + final selectedSearchFilter = ref.read( + trackProvider.select((s) => s.selectedSearchFilter), + ); + if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) { + return; + } + + final text = _urlController.text.trim(); + if (text.isEmpty || text.length < _minLiveSearchChars) return; + if (text.startsWith('http') || text.startsWith('spotify:')) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _lastSearchQuery = null; + _performSearch(text); + }); + }, + ); + if (hasActualResults && isShowingRecentAccess && hasSearchInput && @@ -1360,7 +1455,12 @@ class _HomeTabState extends ConsumerState return SliverToBoxAdapter( child: _buildSearchFilterBar( searchFilters, - selectedSearchFilter, + _displaySearchFilterSelection( + selectedSearchFilter, + defaultSearchTab, + currentSearchProvider, + extensions, + ), colorScheme, ), ); @@ -3269,10 +3369,10 @@ class _HomeTabState extends ConsumerState padding: const EdgeInsets.only(right: 8), child: FilterChip( label: Text(context.l10n.historyFilterAll), - selected: selectedFilter == null, + selected: selectedFilter == 'all', onSelected: (_) { - ref.read(trackProvider.notifier).setSearchFilter(null); - _triggerSearchWithFilter(null); + ref.read(trackProvider.notifier).setSearchFilter('all'); + _triggerSearchWithFilter('all'); }, showCheckmark: false, ), diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 481ec181..364170dc 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -796,37 +796,45 @@ class _MetadataSourceSelector extends ConsumerWidget { const SizedBox(height: 16), Row( children: [ - _SourceChip( - icon: Icons.graphic_eq, - label: defaultProviderLabel, - isSelected: searchProvider.isEmpty, - onTap: () { - if (hasNonDefaultProvider) { - ref.read(settingsProvider.notifier).setSearchProvider(null); - } - }, + Expanded( + child: _SourceChip( + icon: Icons.graphic_eq, + label: defaultProviderLabel, + isSelected: searchProvider.isEmpty, + onTap: () { + if (hasNonDefaultProvider) { + ref.read(settingsProvider.notifier).setSearchProvider( + null, + ); + } + }, + ), ), const SizedBox(width: 8), - _SourceChip( - icon: Icons.waves, - label: 'Tidal', - isSelected: searchProvider == 'tidal', - onTap: () { - ref - .read(settingsProvider.notifier) - .setSearchProvider('tidal'); - }, + Expanded( + child: _SourceChip( + icon: Icons.waves, + label: 'Tidal', + isSelected: searchProvider == 'tidal', + onTap: () { + ref + .read(settingsProvider.notifier) + .setSearchProvider('tidal'); + }, + ), ), const SizedBox(width: 8), - _SourceChip( - icon: Icons.album, - label: 'Qobuz', - isSelected: searchProvider == 'qobuz', - onTap: () { - ref - .read(settingsProvider.notifier) - .setSearchProvider('qobuz'); - }, + Expanded( + child: _SourceChip( + icon: Icons.album, + label: 'Qobuz', + isSelected: searchProvider == 'qobuz', + onTap: () { + ref + .read(settingsProvider.notifier) + .setSearchProvider('qobuz'); + }, + ), ), ], ), @@ -906,6 +914,14 @@ class _DefaultSearchTabSelector extends ConsumerWidget { .read(settingsProvider.notifier) .setDefaultSearchTab('track'), ), + _SourceChip( + icon: Icons.person, + label: context.l10n.searchArtists, + isSelected: selectedTab == 'artist', + onTap: () => ref + .read(settingsProvider.notifier) + .setDefaultSearchTab('artist'), + ), _SourceChip( icon: Icons.album, label: context.l10n.searchAlbums, @@ -947,39 +963,38 @@ class _SourceChip extends StatelessWidget { ) : colorScheme.surfaceContainerHigh; - return Expanded( - child: Material( - color: isSelected ? colorScheme.primaryContainer : unselectedColor, + return Material( + color: isSelected ? colorScheme.primaryContainer : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Column( - children: [ - Icon( - icon, - size: 28, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 28, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, ), - const SizedBox(height: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ), + ], ), ), ),