mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-22 07:56:55 +02:00
feat: add artist search filter and normalize search filter handling
This commit is contained in:
@@ -805,9 +805,6 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string {
|
||||
}
|
||||
|
||||
normalizedBuiltIn := strings.ToLower(providerID)
|
||||
if normalizedBuiltIn == "deezer" {
|
||||
continue
|
||||
}
|
||||
if isBuiltInDownloadProvider(normalizedBuiltIn) {
|
||||
providerID = normalizedBuiltIn
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user