feat: add artist search filter and normalize search filter handling

This commit is contained in:
zarzet
2026-04-13 00:44:35 +07:00
parent 4a90d3f38a
commit a15313e573
6 changed files with 215 additions and 82 deletions
-3
View File
@@ -805,9 +805,6 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string {
}
normalizedBuiltIn := strings.ToLower(providerID)
if normalizedBuiltIn == "deezer" {
continue
}
if isBuiltInDownloadProvider(normalizedBuiltIn) {
providerID = normalizedBuiltIn
}
+21 -1
View File
@@ -603,6 +603,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
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<ExtensionState> {
}).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<ExtensionState> {
}
}
Future<void> _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<bool> ensureSpotifyWebExtensionReady({
bool setAsSearchProvider = true,
}) async {
@@ -849,6 +868,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> _sanitizeDownloadProviderPriority(List<String> input) {
final allowed = getAllDownloadProviders().toSet();
final preferredOrder = getAllDownloadProviders();
final result = <String>[];
for (final provider in input) {
@@ -857,7 +877,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
for (final provider in const ['tidal', 'qobuz']) {
for (final provider in preferredOrder) {
if (!result.contains(provider)) {
result.add(provider);
}
+1 -1
View File
@@ -18,7 +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'};
static const Set<String> _searchTabValues = {'all', 'track', 'artist', 'album'};
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
+5 -4
View File
@@ -584,6 +584,7 @@ class TrackNotifier extends Notifier<TrackState> {
}) 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<TrackState> {
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
)) {
final resolvedFilter = currentFilter ?? 'track';
final resolvedFilter = requestFilter ?? 'track';
Map<String, dynamic>? options;
options = {'filter': resolvedFilter};
await customSearch(
@@ -692,7 +693,7 @@ class TrackNotifier extends Notifier<TrackState> {
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<String, dynamic> results;
@@ -705,7 +706,7 @@ class TrackNotifier extends Notifier<TrackState> {
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
filter: requestFilter,
);
break;
case 'qobuz':
@@ -714,7 +715,7 @@ class TrackNotifier extends Notifier<TrackState> {
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
filter: requestFilter,
);
break;
default:
+118 -18
View File
@@ -492,15 +492,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
}
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<HomeTab>
) {
final preferred = switch (preferredSearchTab) {
'track' => 'track',
'artist' => 'artist',
'album' => 'album',
_ => null,
};
@@ -538,6 +576,31 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
String _displaySearchFilterSelection(
String? selectedSearchFilter,
String preferredSearchTab,
String? currentSearchProvider,
List<Extension> 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<Track> tracks) {
final cached = _searchBucketsCache;
if (cached != null && identical(tracks, _searchBucketsSourceTracks)) {
@@ -695,22 +758,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
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<HomeTab>
(hasHomeFeedExtension || hasExploreContent) &&
hasExploreContent;
ref.listen<String>(
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<HomeTab>
return SliverToBoxAdapter(
child: _buildSearchFilterBar(
searchFilters,
selectedSearchFilter,
_displaySearchFilterSelection(
selectedSearchFilter,
defaultSearchTab,
currentSearchProvider,
extensions,
),
colorScheme,
),
);
@@ -3269,10 +3369,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
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,
),
+70 -55
View File
@@ -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,
),
),
],
),
),
],
),
),
),