From ed60cafb90fb6f2fe99ff2f7cbe1103d498c1460 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 1 Aug 2026 18:01:16 +0700 Subject: [PATCH] fix(search): expose go to album for sparse tracks --- lib/utils/clickable_metadata.dart | 86 +++++++++++++++++++ .../track_collection_quick_actions.dart | 21 ++--- test/preview_button_layout_test.dart | 37 ++++++++ 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index fcf88c09..6df11dc4 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -282,6 +283,91 @@ Future navigateToAlbum( } } +/// Opens the album for a track, enriching sparse search results first when +/// they do not include album identity. This keeps collection actions generic: +/// any metadata extension can supply the missing track fields through search. +Future navigateToTrackAlbum(BuildContext context, Track track) async { + final albumName = track.albumName.trim(); + final albumId = track.albumId?.trim(); + final hasAlbumId = albumId != null && !_isUnknownResourceId(albumId); + + if (albumName.isNotEmpty || hasAlbumId) { + await navigateToAlbum( + context, + albumName: albumName.isNotEmpty ? albumName : context.l10n.trackAlbum, + albumId: hasAlbumId ? albumId : null, + artistName: _preferredAlbumArtist(track), + coverUrl: track.coverUrl, + extensionId: track.source, + ); + return; + } + + final query = [ + track.name.trim(), + track.artistName.trim(), + ].where((part) => part.isNotEmpty).join(' '); + if (query.isEmpty) { + _showUnavailable(context, context.l10n.trackAlbum); + return; + } + + _showLoadingSnackBar(context, 'Looking up album...'); + try { + final searchResult = await _searchMetadataProviders( + context, + query, + filter: 'track', + limit: 20, + sourceProviderId: track.source, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + final matches = searchResult?.items ?? const >[]; + final bestMatch = _pickBestResultByName(matches, track.name); + if (bestMatch == null) { + _showUnavailable(context, context.l10n.trackAlbum); + return; + } + + final providerId = _resolveResultProviderId( + bestMatch, + searchResult?.providerId, + ); + final resolvedTrack = Track.fromBackendMap(bestMatch, source: providerId); + final resolvedAlbumName = resolvedTrack.albumName.trim(); + final resolvedAlbumId = resolvedTrack.albumId?.trim(); + final hasResolvedAlbumId = + resolvedAlbumId != null && !_isUnknownResourceId(resolvedAlbumId); + if (resolvedAlbumName.isEmpty && !hasResolvedAlbumId) { + _showUnavailable(context, context.l10n.trackAlbum); + return; + } + + await navigateToAlbum( + context, + albumName: resolvedAlbumName.isNotEmpty + ? resolvedAlbumName + : context.l10n.trackAlbum, + albumId: hasResolvedAlbumId ? resolvedAlbumId : null, + artistName: _preferredAlbumArtist(resolvedTrack), + coverUrl: resolvedTrack.coverUrl ?? track.coverUrl, + extensionId: providerId, + ); + } catch (e) { + _log.e('Failed to resolve album for track "${track.name}": $e', e); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + _showUnavailable(context, context.l10n.trackAlbum); + } +} + +String _preferredAlbumArtist(Track track) { + final albumArtist = (track.albumArtist ?? '').trim(); + return albumArtist.isNotEmpty ? albumArtist : track.artistName; +} + void _pushArtistScreen( BuildContext context, { required String artistId, diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 58587927..3c760905 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -194,12 +194,11 @@ class _TrackOptionsSheet extends ConsumerWidget { onTap: () => _downloadQualityVariant(context, ref), ), - if (track.albumName.trim().isNotEmpty) - _OptionTile( - icon: Icons.album_outlined, - title: context.l10n.homeGoToAlbum, - onTap: () => _goToAlbum(context), - ), + _OptionTile( + icon: Icons.album_outlined, + title: context.l10n.homeGoToAlbum, + onTap: () => _goToAlbum(context), + ), _OptionTile( icon: isLoved ? Icons.favorite : Icons.favorite_border, @@ -281,16 +280,8 @@ class _TrackOptionsSheet extends ConsumerWidget { context, rootNavigator: true, ).context; - final albumArtist = (track.albumArtist ?? '').trim(); Navigator.pop(context); - await navigateToAlbum( - navigationContext, - albumName: track.albumName, - albumId: track.albumId, - artistName: albumArtist.isNotEmpty ? albumArtist : track.artistName, - coverUrl: track.coverUrl, - extensionId: track.source, - ); + await navigateToTrackAlbum(navigationContext, track); } Future _playLocal(BuildContext context, WidgetRef ref) async { diff --git a/test/preview_button_layout_test.dart b/test/preview_button_layout_test.dart index 467d7c8e..32164132 100644 --- a/test/preview_button_layout_test.dart +++ b/test/preview_button_layout_test.dart @@ -20,6 +20,14 @@ void main() { previewUrl: 'https://example.com/preview.mp3', duration: 180, ); + const sparseSearchTrack = Track( + id: 'search-track', + name: 'Search Track', + artistName: 'Artist', + albumName: '', + duration: 180, + source: 'metadata-extension', + ); Future pumpButton(WidgetTester tester, {MediaItem? currentItem}) async { await tester.pumpWidget( @@ -113,6 +121,35 @@ void main() { expect(find.text('Go to Album'), findsOneWidget); expect(find.byIcon(Icons.album_outlined), findsOneWidget); }); + + testWidgets( + 'search track options keep Go to Album when album data is sparse', + (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_TestSettingsNotifier.new), + libraryCollectionsProvider.overrideWith( + _TestLibraryCollectionsNotifier.new, + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: TrackCollectionQuickActions(track: sparseSearchTrack), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Go to Album'), findsOneWidget); + expect(find.byIcon(Icons.album_outlined), findsOneWidget); + }, + ); } class _TestSettingsNotifier extends SettingsNotifier {