diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 44239750..8cc53f7e 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1072,6 +1072,7 @@ class _AlbumTrackItem extends ConsumerWidget { artistName: track.artistName, artistId: track.artistId, coverUrl: track.coverUrl, + extensionId: track.source, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index a7fd32da..ea852fbe 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -6188,6 +6188,7 @@ class _QueueTabState extends ConsumerState { artistName: item.track.artistName, artistId: item.track.artistId, coverUrl: item.track.coverUrl, + extensionId: item.track.source, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall @@ -6790,6 +6791,7 @@ class _QueueTabState extends ConsumerState { ClickableArtistName( artistName: item.artistName, coverUrl: item.coverUrl, + extensionId: item.historyItem?.service, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -7092,6 +7094,7 @@ class _QueueTabState extends ConsumerState { ClickableArtistName( artistName: item.artistName, coverUrl: item.coverUrl, + extensionId: item.historyItem?.service, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall?.copyWith( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 5977eb17..17730b9d 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -202,6 +202,7 @@ class _SearchTrackTile extends ConsumerWidget { artistName: track.artistName, artistId: track.artistId, coverUrl: track.coverUrl, + extensionId: track.source, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index 7180cb2c..1d3ca921 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -157,7 +157,7 @@ Future navigateToArtist( context, artistName, filter: 'artist', - limit: 3, + limit: 20, sourceProviderId: extensionId, ); if (!context.mounted) return; @@ -169,16 +169,11 @@ Future navigateToArtist( return; } - Map? bestMatch; - final lowerName = artistName.toLowerCase().trim(); - for (final a in artistList) { - final name = (a['name'] as String? ?? '').toLowerCase().trim(); - if (name == lowerName) { - bestMatch = a; - break; - } + final bestMatch = _pickBestResultByName(artistList, artistName); + if (bestMatch == null) { + _showUnavailable(context, context.l10n.trackArtist); + return; } - bestMatch ??= artistList.first; final resolvedId = bestMatch['id'] as String? ?? ''; final resolvedName = bestMatch['name'] as String? ?? artistName; @@ -240,7 +235,7 @@ Future navigateToAlbum( context, query, filter: 'album', - limit: 5, + limit: 20, sourceProviderId: extensionId, ); if (!context.mounted) return; @@ -252,16 +247,11 @@ Future navigateToAlbum( return; } - Map? bestMatch; - final lowerName = albumName.toLowerCase().trim(); - for (final a in albumList) { - final name = (a['name'] as String? ?? '').toLowerCase().trim(); - if (name == lowerName) { - bestMatch = a; - break; - } + final bestMatch = _pickBestResultByName(albumList, albumName); + if (bestMatch == null) { + _showUnavailable(context, 'Album'); + return; } - bestMatch ??= albumList.first; final resolvedId = bestMatch['id'] as String? ?? ''; final resolvedName = bestMatch['name'] as String? ?? albumName; @@ -579,34 +569,33 @@ List<_ArtistTapTarget> _buildArtistTapTargets( } final parsedIds = _parseArtistIds(rawArtistIds); - if (parsedIds.length == uniqueNames.length) { - return List<_ArtistTapTarget>.generate( - uniqueNames.length, - (index) => _ArtistTapTarget( - name: uniqueNames[index], - artistId: parsedIds[index], - ), - growable: false, - ); + if (parsedIds.isEmpty || !parsedIds.any((id) => id != null)) { + return uniqueNames + .map((name) => _ArtistTapTarget(name: name)) + .toList(growable: false); } - return uniqueNames - .map((name) => _ArtistTapTarget(name: name)) - .toList(growable: false); + // Providers may return one id per artist (aligned with the names) or only + // the primary artist's id. Map ids to names positionally, preserving empty + // slots as null, so each tapped artist navigates by its own id when known. + return List<_ArtistTapTarget>.generate( + uniqueNames.length, + (index) => _ArtistTapTarget( + name: uniqueNames[index], + artistId: index < parsedIds.length ? parsedIds[index] : null, + ), + growable: false, + ); } -List _parseArtistIds(String? rawArtistIds) { +List _parseArtistIds(String? rawArtistIds) { final raw = rawArtistIds?.trim(); if (raw == null || raw.isEmpty) return const []; - final parsed = []; - for (final part in raw.split(RegExp(r'\s*,\s*'))) { - final normalized = _normalizeArtistId(part); - if (normalized != null) { - parsed.add(normalized); - } - } - return parsed; + return raw + .split(RegExp(r'\s*,\s*')) + .map(_normalizeArtistId) + .toList(growable: false); } String? _normalizeArtistId(String? artistId) { @@ -644,6 +633,90 @@ bool _canNavigateArtistDirectly({ return _spotifyArtistIdPattern.hasMatch(artistId); } +/// Selects the result whose name best matches [query] instead of blindly +/// trusting the provider's first result. This prevents tapping an artist like +/// "creo" from opening a completely unrelated artist (e.g. "Tyler, the +/// Creator") just because the provider ranked it first. +Map? _pickBestResultByName( + List> results, + String query, +) { + if (results.isEmpty) return null; + + final normalizedQuery = _normalizeForMatch(query); + if (normalizedQuery.isEmpty) return results.first; + + Map? best; + double bestScore = -1; + for (final result in results) { + final name = result['name'] as String? ?? ''; + final normalizedName = _normalizeForMatch(name); + if (normalizedName.isEmpty) continue; + + if (normalizedName == normalizedQuery) { + return result; + } + + final score = _nameMatchScore(normalizedQuery, normalizedName); + if (score > bestScore) { + bestScore = score; + best = result; + } + } + + // Accept a fuzzy match only when it is reasonably close. Otherwise fall back + // to the provider's own top result so we never crash on an empty match. + if (best != null && bestScore >= 0.5) { + if (bestScore < 0.85) { + _log.w( + 'No exact match for "$query"; using closest result ' + '"${best['name']}" (score ${bestScore.toStringAsFixed(2)})', + ); + } + return best; + } + + _log.w( + 'No close match for "$query" among ${results.length} results; ' + 'falling back to first result "${results.first['name']}"', + ); + return results.first; +} + +String _normalizeForMatch(String value) { + return value + .toLowerCase() + .replaceAll(RegExp(r'[^\p{L}\p{N}\s]', unicode: true), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); +} + +/// Returns a similarity score in [0, 1] between two normalized names. +double _nameMatchScore(String query, String candidate) { + if (query == candidate) return 1.0; + + final queryTokens = query.split(' ').where((t) => t.isNotEmpty).toSet(); + final candidateTokens = candidate + .split(' ') + .where((t) => t.isNotEmpty) + .toSet(); + if (queryTokens.isEmpty || candidateTokens.isEmpty) return 0; + + final intersection = queryTokens.intersection(candidateTokens).length; + final union = queryTokens.union(candidateTokens).length; + final jaccard = union == 0 ? 0.0 : intersection / union; + + // Reward full substring containment of the (shorter) query in the candidate. + double containment = 0; + if (candidate.contains(query) || query.contains(candidate)) { + final shorter = query.length < candidate.length ? query : candidate; + final longer = query.length < candidate.length ? candidate : query; + containment = longer.isEmpty ? 0 : shorter.length / longer.length; + } + + return jaccard > containment ? jaccard : containment; +} + final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); class ClickableAlbumName extends StatelessWidget { diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 2db506c9..666425b9 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -142,6 +142,7 @@ class _TrackOptionsSheet extends ConsumerWidget { artistName: track.artistName, artistId: track.artistId, coverUrl: track.coverUrl, + extensionId: track.source, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( color: colorScheme.onSurfaceVariant,